前言

Elm提出于2012年,最初出现在Evan Czaplicki的毕业论文中(掩面)。目标是提出一个面向web的函数式编程语言。它拥有诸多特性

  • 强类型语言
  • 一次编译,no runtime error
  • 状态不可修改
  • 函数是一级公民等

Elm是门新语言,它是一个类似React(但绝对和它不一样)的前端框架。在Web App的设计它甚至启发了Flux设计的提出。在如今前端框架吸收函数式编程以及强类型语言优点的形势下,学习Elm可能会开启对Web前端开发的重新认识。

再看完下面的介绍后,建议阅读两篇对Elm的评价,相信更有助于对这门年轻语言特点的理解:

准备工作

Elm是通过将代码编译成JavaScript工作的。一个最简单的Elm App大概像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)

main =
Html.beginnerProgram { model = 0, view = view, update = update }

type Msg = Increment | Decrement

update msg model =
case msg of
Increment ->
model + 1

Decrement ->
model - 1

view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, div [] [ text (toString model) ]
, button [ onClick Increment ] [ text "+" ]
]

那么这样一个Elm文件是怎么应用在页面中呢?

Elm安装完成后,会有4个Elm相关的包:

  • elm-repl 命令行操作,在cli中感受Elm的语法特点
  • elm-reactor 快速脚手架搭建
  • elm-make 编译工具
  • elm-package 包管理工具

麻雀虽小,五脏俱全。

语法特点

Elm在语法上不同于C风格的所有语言,和Haskell更为靠近。

注释

--开启单行注释,结合{}进行多行注释

1
2
3
4
5
-- a single line comment

{- a multiline comment
{- can be nested -}
-}

类型

Elm是强类型语言,有Bool,Int,Float,Char,String 5种基本类型。有意思的是,Elm没有Null。这也是特别设计的。

注意:Elm中Char类型用单引号`'`包裹,而String类型用双引号`"`包裹。

除了基本类型,Elm中还有List,Array,Tuple,Dict,Record几种泛型。其中List,Record设计类似JavaScript中的Array和Object。

在Elm中`Array`和`List`是两种不同的类型,前者通过链表实现,后者通过Relaxed Radix Tree实现。前者在增删节点上高效,后者在随机查询时高效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[1,2,3,4]
1 :: [2,3,4]
1 :: 2 :: 3 :: 4 :: []
point = -- create a record
{ x = 3, y = 4 }

point.x -- access field

List.map .x [point,{x=0,y=0}] -- field access function

{ point | x = 6 } -- update a field

{ point | -- update many fields
x = point.x + 1,
y = point.y + 1
}

*(值得注意的是,上面的::.x实际上都是函数)*。

类型声明

类型是Elm中重要的一环,每个变量都需要有类型,编译时需要进行检查。因此显式地声明变量类型很重要。

当我们需要通过基本类型构造复杂类型时,可以通过type alias的形式为record创建新的类型。在新类型创建的同时,会同步生成一个record构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
-- Before:麻烦的函数写法
hasBio : { name : String, bio : String, pic : String } -> Bool
hasBio user =
String.length user.bio > 0
-- After:简便的写法
type alias User =
{ name : String
, bio : String
, pic : String
}
hasBio : User -> Bool
hasBio user =
String.length user.bio > 0

Union类型

之所以单独拿出来说,是因为Union Type的设计几乎是Elm的精髓。首先,它类似于枚举(enum)的概念,通过type enum = A | B | C的形式定义一个类型。这是简单的Union Type的使用。

Union Type还有Tagged Union的用法。即下面这样。这意味着User可以是不带信息的Anonymous或带有String信息的NamedAnonymousNamed是User的两个构造函数。其中Named接受一个String类型入参构造User类型。

1
type User = Anonymous | Named String
注意:不同Union Type间的Tag构造函数不能同名,即不能出现下面的情况。同时Tag后的额外消息是泛型`a`时,需要在type名后也加上,便于type check
1
type User = Named | Named String -- Wrong!

结合上面的特点,可以很容易地将相似数据结构或设计抽象为单一模板,如举例中的将时间轴、日志等统一抽象为widget。思路:逐个击破,合而为一

由于Union Type可以递归定义,通过Union Type甚至可以构建链表和二叉树

1
2
type List a = Empty | Node a (List a)
type Tree a = Empty | Node a (Tree a) (Tree a)

此外,Elm中的错误处理MaybeResult也是基于Union Type实现的。

因为Union Type的不同子类型可以有自己独特的构造函数,且支持解构赋值,因此非常适合用作实现状态管理的事件。Web App中的update函数接受的Html Msg类型实际上也是Union Type实现的。

函数

除了不能手动更改状态的变量(因此递归完全替代了循环结构),函数是Elm中最常见的存在。命令式编程中,函数用作告诉电脑该怎么做。函数式编程中,函数用作描述一种映射关系,告诉电脑要什么。Elm中函数像下面这样声明:

1
2
3
4
5
6
7
8
9
square n =
n^2

hypotenuse : Float -> Float -> Float
hypotenuse a b =
sqrt (square a + square b)

distance (a,b) (x,y) =
hypotenuse (a-x) (b-y)

入参在上,返回值在下。入参间通过空格隔开,由于Elm支持函数柯里化,所以在函数的类型声明中,也是通过->隔开每个入参以及返回值的。由于函数变成了纯粹的“通道”,函数体中声明临时变量的语法通过let ... in的形式实现。

Elm中也有匿名函数,像下面这样,由反斜线\开头:

1
2
3
4
5
square =
\n -> n^2

squares =
List.map (\n -> n^2) (List.range 1 100)

函数间通过|><|连接减少括号的使用。例如下面这样

1
2
3
4
5
6
7
8
9
viewNames1 names =
String.join ", " (List.sort names)

viewNames2 names =
names
|> List.sort
|> String.join ", "

-- (arg |> func) is the same as (func arg)

同时,可以通过>><<构造复合函数。``用来将第n个函数入参前置,使得居于更符合语法习惯,如buyMilk `then` drinkMilkbuyMilk参数被提前。n可以通过infixr设置。

其中可能较难理解的是递归完全替代了循环结构,Elm类似其他函数式语言,没有forwhile。它用描述问题的方式,通过递归解决普通的循环问题,下面举两个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
reverse : List a -> List a
reverse list =
case list of
[] -> []
(x::xs) -> reverse xs ++ [x]

quicksort : List comparable -> List comparable
quicksort list =
case list of
[] -> []
(x::xs) ->
let
smallerSorted = quicksort (List.filter ((>) x) xs)
biggerSorted = quicksort (List.filter ((<=) x) xs)
in
smallerSorted ++ [x] ++ biggerSorted

可以找到规律:

Usually you define an edge case and then you define a function that does something between some element and the function applied to the rest.

更具体的解释可以看参考链接3。

控制结构

Elm中没有forwhile循环(都通过递归实现了)。但是存在ifcase语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
if powerLevel > 9000 then "OVER 9000!!!" else "meh"
case maybe of
Just xs -> xs
Nothing -> []

case xs of
hd::tl -> Just (hd,tl)
[] -> Nothing

case n of
0 -> 1
1 -> 1
_ -> fib (n-1) + fib (n-2) -- _表示不接受入参

杂项

  • ++连接字符串,+表示相加
  • Elm不会进行强制类型转换
  • 缩进会影响语句解析
  • list中所有元素类型必须一致
  • tuples设计类似python
  • records类型不允许访问不存在的属性
  • //用来进行C风格的除法
  • Elm中!用于连接Model和Cmd,用/=表示!=,同not表示!

Elm的语言设定大不同于C风格,所以,多写去熟悉它的语法风格吧。

Elm架构

Elm构建Web App的架构为MVU(不考虑Cmd和Subscription的话),这和目前大部分MVVM框架一样,致力于减少UI维护,减少不必要的状态修改来更好地定位错误。有意思的是,Elm也采用了Virtual DOM的设计

  • Model,类似Web App的state,通常为records类型
  • Update,更新state的唯一方式(类似于action或commit),通常类型为Msg -> Model -> (Model, Cmd Msg)
  • View,根据state渲染HTML的方式,通常类型为Model -> Html Msg

Update部分,通常像下面这样,接受Union Type的Msg,并通过case ... of接受到附在Msg上的payload。

1
2
3
4
5
update : Msg -> Model -> Model
update msg model =
case msg of
Change newContent ->
{ model | content = newContent }

View部分,所有常用的标签名和属性名都被封装为函数,接受属性列表和子元素列表两个入参,像下面这样。

1
2
3
4
5
6
view : Model -> Html Msg
view model =
div []
[ input [ placeholder "Text to reverse", onInput Change ] []
, div [] [ text (String.reverse model.content) ]
]

将M、V、U三部分结合在一起就可以构造简单的web应用了。当有异步任务等复杂情况出现时,需要通过Cmd和Subscription实现。

Effects

Elm Architecture

说这两位之前,我们要回顾下之前的工作流。仔细观察就能发现,我们所做的事只是描述了接受Html Msg后如何生产新的Model并根据新的Model生产新的Html Msg交给Elm Runtime,之后的脏活累活都交给Elm干了。根据Elm的描述,除了vDOM外,它还使用了下面两个手段提升效率:

  • Skip Work,通过lazy(类似React的shouldComponentUpdate)限制更新频率
  • Align Work,通过Html.keyed(类似React的key)减少无意义的diffing。

回到正题,Cmd和Subscription也是对Msg所做的外层包装而已。除了能向Elm Runtime pipe Html Msg外,当然也可以发送命令或订阅事件

。Html, Cmd, Sub三者实际上没太大区别,都是我们将生成的数据交给Elm Runtime,剩下的做甩手掌柜。它可以帮助我们:

  • 更好的debug
  • 确保Elm函数的线性时不变
  • 优化HTTP连接,缓存effects

下面是一个Sub的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
Time.every second Tick

-- UPDATE

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Tick newTime ->
(newTime, Cmd.none)

错误处理

之前提到过Elm致力于“编辑时无bug=>Runtime无bug”。除了强类型和无状态(实际上是Immutable的状态)外,还有Maybe和Result的辅助。

Elm treats errors as data.

参考null的糟糕设计,Elm通过

1
type Maybe a = Nothing | Just a

定义了Maybe类型。结合case of的特殊情况处理,通过option types的方法替代Nullcore/Maybe模块还有withDefaultandThen等其他方法。

类似于Maybe,Result用Union Type的形式定义了不可靠操作的返回值。

1
type Result error value = Err error | Ok value

通过Err和Ok两种类型,隐含了其他语言中的try catch操作,避免exception的出现。Elm库函数是实现了Result类型的,如Http.get。Result预定义在core/Result

Task

另外,Elm中有Task用来处理异步操作中的失败情况。类似于JS中的Promise。使用思路是先通过succeed方法和fail方法定义Task再使用。

1
2
3
4
type alias Task err ok =
Task err ok
succeed : a -> Task x a
fail : x -> Task x a

在Elm由0.17升级到0.18时,有两种方式执行一个Task。Task.performTask.attempt。前者针对从不返回错误的task,后者针对可能出错的task。更重要的是,在Task.attempt中结合了熟悉的Result模型。

1
2
3
4
5
6
7
8
9
10
11
12
13
perform : (a -> msg) -> Task Never a -> Cmd msg
attempt : (Result x a -> msg) -> Task x a -> Cmd msg
-- example
type Msg = Click | NewTime Time

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Click ->
( model, Task.perform NewTime Time.now )

NewTime time ->
...

互操作性

Elm的互操作性体现在和JSON以及JavaScript上。

与JSON

通过Json.Decode和Json.Encode完成相关工作。解析部分由decodeString完成。

1
decodeString : Decoder a -> String -> Result String a

由第一个参数指定decoder类型,如 decodeString int "42"就指定了一个整数的解析器。在多数情况下,JSON字符串并没这么简单。这时,需要先利用基本的int、string、list、dict解析器结合map2、map3等构造相应的Decoder,再交给decodeString处理。函数返回Result类型

1
2
3
4
5
6
7
import Json.Decode exposing (..)

type alias Point = { x : Int, y : Int }

pointDecoder = map2 Point (field "x" int) (field "y" int)

decodeString pointDecoder """{ "x": 3, "y": 4 }""" -- Ok { x = 3, y = 4 } : Result String Point

是不是很麻烦。不像JavaScript里一个JSON.parse()完事,确实Elm解析JSON的笨拙为人诟病,官方的pipeline包通过|>组合Decoder,让语法稍微好了一点。最新的0.18版本下有json-extra提供更方便的decoder选择。

1
2
3
4
5
6
7
import Json.Decode.Pipeline exposing (decode, required)

pointDecoder : Decoder Point
pointDecoder =
decode Point
|> required "x" int
|> required "y" int

可这距离JSON.parse还是不够啊。于是有人写了个根据Records类型生成decoder的工具,或者在线生成

相比之下,encode过程就简单多了。

与JavaScript

有两种方式,port或flag。

前者类似于在Elm应用上凿洞,用类似订阅发布的模式工作。需要在文件开头的module声明前,额外加上port关键词,同时需要暴露的接口前也需要port关键词。

1
2
3
4
5
6
7
8
9
port module Spelling exposing (..)

...

-- port for sending strings out to JavaScript
port check : String -> Cmd msg

-- port for listening for suggestions from JavaScript
port suggestions : (List String -> msg) -> Sub msg
1
2
3
4
5
6
7
8
9
10
11
var app = Elm.Spelling.fullscreen();

app.ports.check.subscribe(function(word) {
var suggestions = spellCheck(word);
app.ports.suggestions.send(suggestions);
});

function spellCheck(word) {
// have a real implementation!
return [];
}

后者暴露program的init给外部JavaScript。在声明Elm program时,使用programWithFlags而非program

1
2
3
4
5
6
init : Flags -> ( Model, Cmd Msg )
init flags =
...

main =
programWithFlags { init = init, ... }
1
2
3
4
var app = Elm.MyApp.fullscreen({
user: 'Tom',
token: '12345'
});

Elm的思路是不向后兼容,避免过去问题的引入。只通过port和flags的方式,借由Elm runtime和外部JS沟通,避免自身的runtime exception,把问题只留在JavaScript部分。

工程实践

使用Elm编写简单的应用时,可能一个.elm文件完成后,就可以直接elm-make index.elm --output index.js就OK了。当工程较大时(目前还没怎么看到生产环境用Elm的),必然需要拆分组件。

Elm在设计上,拆分的比MVVM框架更细。它拆分到了函数的级别,由于它pure function的特点,不受状态的束缚,重用函数比MVVM框架顾虑少太多了。如,重用view函数就相当于React和Vue中的函数式组件。重用update函数,可以实现MVVM中“组件A改变组件B,组件B改变组件C,组件C改变组件A”的史诗级难题。

封装和应用是通过module ... exposing ...import ... exposing ...的语法完成的。Elm会去elm-package.json中的source-directories以及dependencies中声明的路径和包下寻找import对应的东西。剩下的,只要控制好复用程度,在习惯Elm语法后,就可以轻松且高逼格地编写Web应用了。

Elm编写好,且通过elm-make成功编译后,会得到一个.js文件,在需要的HTML文件中引入,会得到一个Elm全局对象,存储了所有的program。每个program都有embed方法和fullscreen方法绑定在HTML文件中。之后,大功告成!

参考作者所写的TODOMVC是个不错的开始。

FAQ

Q:Where are my components? And how do they communicate?
A:参见Scaling The Elm Architecture

Q:What’s the “Elm style”?
A:回头看看前言一节最后放的两个Elm开发感受,Elm中,View和Updates两部分均可以拆解成多个/组helper functions。最后在一个门面文件中汇总。Elm将state、updates、view放在一个文件,对开发更友好。Elm的优势在于函数式编程特点的帮助(无副作用/纯函数/强类型/出色的错误处理机制)和对人机交互模式的优异抽象。它的问题在

  • 不完善的文档
  • 蹩脚的互操作(interop)设计(port和flag)
  • 学习曲线陡峭
  • 缺乏成熟的成套解决方案
  • 坑爹的JSON支持

Q:Who will save my front-end routing?
A:有的,借助Elm Navigation package就可以做到,具体如何和状态变化绑定起来参见tutorial的routing部分

参考

下面的大部分问题来自Github的这个仓库,排名不分先后

46. html5中元素的data-*属性

用来存储用户自定义数据,可以通过Element.dataset.foo的形式读取。

47. CSS在前JS在后是一定的么

不。调试js,日志脚本,shim或polyfill以及动态修改整个页面style的JS代码需要放在<head>中。

48. progressive rendering

渐进式渲染技术是一种尽量快地将渲染结果展现给用户的技术。比如懒加载,优先渲染高优先级元素等手段。目标是将用户关心的内容优先展示出来,将页面信息一点点释放给用户。

49. normalize/reset CSS

知乎中一段说明写得太好,这里摘录下来。

CSS Reset 是革命党,CSS Reset 里最激进那一派提倡不管你小子有用没用,通通给我脱了那身衣服,凭什么你 body 出生就穿一圈 margin,凭什么你姓 h 的比别人吃得胖,凭什么你 ul 戴一胳膊珠子。于是 *{margin:0;} 等等运动,把人家全拍扁了。看似是众生平等了,实则是浪费了资源又占不到便宜,有求于人家的时候还得贱贱地给加回去,实在需要人家的默认样式了怎么办?人家锅都扔炉子里烧了,自己看着办吧。

Normalize.css 是改良派。他们提倡,各个元素都有其存在的道理,简单粗暴地一视同仁是不好的。body 那一圈确实挤压了页面的生存空间,那就改掉。士农工商,谁有谁的作用,给他们制定个规范,确保他们在任何浏览器里都干好自己的活儿。

Normalize.css是一种CSS reset的替代方案。Reset清除了所有浏览器默认样式,强调不同浏览器间的一致性。但是在我们需要浏览器默认样式时,就需要自己重头再来。Normalize是两位设计者研究不同浏览器默认样式差异后,设计的弥补浏览器bug保证一致性的CSS。现在大多数的网站已经抛弃了Reset.css而选择Normalize.css。

50. BFC是什么

BFC意为块格式化上下文(block formatting context),BFC表示一个区域,在区域内的所有子元素共同构成一个块级上下文。有4中手段可以触发BFC:

  • position: 不为static和relative
  • display: table-cell,table-caption,inline-block,flex,inline-flex
  • float: 不为none
  • overflow: 不为visible

相对来说通过overflow属性触发BFC对已有内容影响最小。利用BFC可以实现很多特性:

  • 消除box间margin的collapse现象
  • 包裹浮动元素,防止高度collapse
  • BFC不与其他元素相交叉,避免文字围绕图片,实现多栏布局

51. 清除浮动的一些手段

  • 在浮动元素后创建一个空的div标签,为之设置clear: both
  • 触发BFC,一般通过overflow属性触发
  • 使用伪类:after,设置clear: both

52. CSS sprites怎么实现和优缺点

将多张小图拼成大图后,通过background-imagebackground-position更改背景。优点是省流量,更换图标整体风格快;缺点是添加新图片麻烦。

53. 图片替代方案

图片替代指使用图片替代文字,同时保证图片可读,通常用于Logo。有下面几种方案:

  • display: none + background-image
  • text-ident: 9999px + background-image
  • margin-left: -9999px + background-image (盒模型很大,低效)
  • img alt属性
  • font-size或color: #fff

54. 解决browser-specific问题

写patches(独立的CSS文件)处理

55. 如何面对低等级浏览器

polyfill,graceful degration

56. 预处理语言的优缺点

优点:更加友善,更好的特性,语法糖
缺点:需要编译,应用场景局限

57. display有哪些可选值

  • none
  • inherit
  • initial
  • unset
  • block
  • inline
  • inline-block
  • flow
  • table
  • flex
  • grid
  • list-item
  • table-row-group
  • table-row
  • table-header-group
  • table-footer-group
  • table-cell
  • table-column-group
  • table-column
  • table-caption
  • inline-flex
  • inline-grid
  • inline-table
  • inline-list-item

58. translate和absolute的选择

两者并无优劣之分。只有应用场景的不同。translate可以实现复杂的位移和变形,absolute用在固定的定位时更方便

59. 视觉上隐藏一个元素

  1. position: fix + left: -9999px
  2. clip + width/height + overflow
  3. visibility: hidden

60. grid system

除开最新的Grid布局。可以通过inline-block + margin实现。grid布局目前支持还较差。这里是一篇很不错的介绍。

61. 高效的CSS写法

这里有一堆CSS编码风格。总体来说,要注意

  • 避免全局选择
  • 让选择器更加具体化
  • 减少不必要的选择器
  • 减少选择器的过深嵌套
  • 尽可能少使用表达式(即calc, rgba这些)
  • CSS放在头部

62. CSS匹配顺序

先构建DOM树,再从右至左地匹配CSS选择器

63. 盒模型

DOM元素以盒的形式呈现,包裹住真正的内容,有margin/border/padding/content四部分,width在默认情况下仅指content部分的宽度,height同理。若想改变盒模型,可以设置box-sizing属性

64. flex

flex是弹性布局。借助flex布局,可以很轻松地实现居中置右等使用默认方法难以实现的效果。flex布局有两个轴:主轴和交叉轴。元素在主轴方向上排布,在位置不够时,会沿交叉轴推挤到下一行或下一列。flex相关的CSS属性同时针对容器和项目。(下方加粗为默认值)

针对容器的属性有

  • flex-direction 主轴方向。可选row/column/reverse
  • flex-wrap 换行选项。可选nowrap/wrap/wrap-reverse
  • flex-flow 综合上面两个选项,如row wrap
  • justify-content 主轴上对齐方式。可选flex-start/flex-end/center/space-between/space-around
  • align-items交叉轴对齐方式。可选flex-start/flex-end/center/stretch/baseline
  • align-contents主轴间对齐方式。可选flex-start/end/center/space-between/space-around/stretch

针对项目的属性有

  • order 项目顺序,默认按照书写顺序排列
  • flex-grow 当容器主轴上长度足够时,该项目在主轴方向上的长度,默认为1,项目间按照该值比例分配
  • flex-shrink 当容器主轴上长度不够时,类似flex-grow处理
  • flex-basis 容器默认的主轴方向长度,也按比例分配
  • align-self 该项目的对齐方式

65. 适应式/响应式布局

responsive:响应式布局,使用同一种布局响应浏览器窗口的连续变化
adaptive:适应式布局,在视口特定大小时改变外观或样式,是离散的

66. DOM事件代理,冒泡和捕获两阶段

DOM事件代理是指,在DOM2级标准中,事件触发有捕获和冒泡两阶段,所以可以将事件监听器绑定在父节点上,减少EventListener的数目。细节可以参见之前做过的一则笔记

67. null, nudefined, undeclared三者的区别

null: 是Object类型,表示空对象,多用来表示还未赋值的对象
undefined: 是基础类型,表示没有定义的变量或属性
undecided: 只是一种称呼,表示没有用var, constlet声明的变量,默认为全局变量,应该避免出现这种情况

68. 匿名函数的应用场景

主要用作返回值或输入参数。

69. host object和native object的区别

前者是用户定义的对象类型,后者是环境自带的原生对象。尽量避免修改native object(包括增删改)。

70. Function.prototype.bind的使用场景

在setTimeout和listener handler中最常用到

71. feature detection/feature inference/UA

由于浏览器之间有各自的特性差异,这三种手段用于保证代码在浏览器间的兼容性。

  • feature detection 检测特性是否存在再使用,比较保险科学
  • feature inference 通过某特性是否存在推断另一特性是否存在,有风险,避免使用
  • UA 直接通过header中的User Agent来得到浏览器信息,建议迫不得已不使用

72. AJAX技术的优劣

优:用户体验好,局部刷新速度快,可以用于实现界面和数据分离
劣:相对来说较难调试,需要解决跨域问题,搜索引擎支持即SEO弱,会遇到移动端支持问题

73. JS templating

JS中的模板技术,如在backbone中使用的underscore的_.template方法。在xtemplate支持下,也可以在页面中指定<script type="x-template">的形式声明,

74. 如何理解不要改动built-in特性

浏览器和标准都是在不断变化的,此刻对built-in特性做的修改在之后浏览器或标注的呢更新后可能会埋下很深的坑。

75. 如何区分[]和{}

  • Object.Prototype.toString.call()
  • [].concat
  • instanceof
  • ES6中新增的方法Array.isArray

76. tenary operator

JS中唯一的三元操作符

77. DOM中attributes和properties的区别

节点的特性(attributes)用Attr类型表示。直观上讲,特性就是元素attributes属性中的节点,即在tag中声明的各特性名,以下面的标签为例:

1
<input type="text" value="John" />

该DOM节点有两个特性:typevalueAttr也是Node的一种,nodeType为2,Attr对象有三个属性

  • name 特性名称
  • value 特性的值
  • specified 特性是否指定在代码中,抑或是默认的

节点的属性(properties)则指对应的DOM对象的属性,不论是继承自Node或是Element类型的,还是自身类型自带的。比如上面同样的例子,该DOM节点具有children, childNodes, className等。

节点的属性和特性会有重合的部分,如id, type等,因DOM节点而异。上面的例子里,attribute中的value指声明在标签上的value默认值,而properties中的value则指该input标签当前的内容。

78. "use strict";

严格模式在ES5中引入,通过直接定义一个不赋给任何变量的上述字符串进入。可以选择在全局或是函数作用域内开启。

  • 严格模式下对默认模式下会静默出错的代码显式报错
  • 严格模式下禁止不合理的行为,如声明了两个一样的属性名
  • 严格模式还淘汰了一些属性,如arguments.calleearguments.caller; 同时,限制一些不安全的使用,如witheval
  • 严格模式抑制了this的值
  • 严格模式下,对未来版本可能用到的保留字禁止用户访问

不过,由于严格模式下代码的解析规则会不大一样,建议只在需要测试的特定函数内开启严格模式,

79. ready和load event的区别

  • ready在DOM元素加载完成后触发
  • load在页面所有资源请求完成后触发(包括图片、视频、音频等)

80. SPA的SEO优化

采用预渲染技术,或为爬虫专门准备静态页面

81. event loop,call stack和task queue

见这篇笔记

82. JavaScript中的对象和继承

见这篇笔记

不过,在ES6中引入了强类型OOP语言中传统的对象和继承语法。

  • 使用class关键字定义类,类中用constructor定义构造函数,使用publicprivate修饰成员级别
  • 可以在成员前指定getset为成员指定setter和getter函数
  • 通过extends实现继承

83. promise

见之前做过的一篇笔记

84. 提升有滚动条时的动画渲染性能

在CSS中为will-change属性指定动画要改变的CSS属性,参见MDN上的介绍

85. layout, painting, composition

浏览器解析,绘制,组合网页的过程。DOM操作可能会触发回流(reflow)或重绘(repainting),后者代价更小,建议减少频繁的DOM操作

86. 一些HTTP 1.1的header

  • Accept 接受文件的类型
  • Accept-Charset/Accept-Encoding 可以接受的文件字符集和编码
  • Age 从缓存实体产生到现在经历的时间
  • Allow 允许使用的HTTP方法
  • Cache-control 使用的缓存策略
  • Content-Encoding 响应体使用编码
  • Content-Lenght 响应体长度
  • Content-Range 响应体范围,用于部分下载(服务端的返回)
  • Content-Type 响应的媒体类型
  • Date 消息的发送时间
  • Etag html实体的唯一标识,用于缓存对比
  • Expires 缓存实体过期时间
  • Host 服务器的主机名,通常是请求资源的URL
  • Location 重定向的地址
  • Pragma 用于向后兼容还没有Cache-Control的HTTP1.0版本,通常只用作Pragma: no-cache
  • Range 请求资源的部分内容,一般用在多线程下载(客户端发起)
  • Referer 当前请求从哪个地址发起
  • Server 服务器端使用的软件信息
  • Transfer-Encoding 传输内容所用的协议类型
  • Upgrade 切换到额外的通信协议,服务端需要返回101状态码,并指定升级的协议名
  • User-Agent 请求发起自什么终端
  • Vary 列出一个相应字段列表,告知服务器当URL对应不同版本资源时,如何选择合适版本
  • Via 用在proxies中,表示使用的协议,版本以及起始端

更多header参考W3C文档

87. HTTP actions

  • OPTIONS 描述目标资源的通信选项
  • GET 获取数据
  • HEAD 类似GET,但是没有响应体
  • POST 将实体提交给服务器
  • PUT 用请求payload替换目标资源
  • PATCH 对资源部分修改
  • DELETE 删除指定资源
  • TRACE 沿着到目标资源的路径执行一个消息环回测试
  • CONNECT 建立一个到由目标资源标识的服务器的隧道

88. JS内存泄漏

  • 意外的全局变量
  • 被遗忘的setInterval
  • 脱离文档的DOM引用
  • 不合理的闭包

Chrome下可以通过Timeline/Profile选项卡查看内存使用情况,避免上述情况出现。

89. rem,em,px

Question 24

90. JS数据类型

基础类型:

  • Undefined
  • Null
  • Number(包括NaN Infinity)
  • Boolean
  • String

其余都是引用类型。更多参加这里

91. Object.assignObject.create

  • Object.assign 将传入变量的可枚举属性和已有属性合并
  • Object.create 创建一个以传入对象为__proto__的对象

92. 回流和重绘

还是参见how browsers work。为了减少回流,有下面一些推荐实践:

  • 一次性改变样式,如用class
  • requestAnimationFrame推迟回流
  • 虚拟DOM
  • 使用documentFragment

93. 排序算法

稳定:插冒归基;不稳定:快选堆希。实现略。

94. CSS/JS跨浏览器兼容问题

  • 明确产品的兼容版本方案,选择合适的技术栈
  • normalize.css polyfill/shim保证兼容
  • 在符合W3C标准浏览器下表现良好,旧浏览器下保证可用性,提示升级即可
  • CSS hack(特殊的选择器,条件样式表)

95. xss和csrf的防御

XSS(Cross Site Script,跨站脚本攻击),分为反射式,存储式,前者只对特定用户生效,如存储在个人资料中的脚本,后者对访问网站的所有用户生效,如攻击站点本身代码。防御转义时,不仅要监测<script>标签,对可以书写JavaScript的href属性,src属性,甚至imgonerror等事件也要做防御。

CSRF(Cross-site Request Forgery,跨站请求伪造),意为恶意网站通过用户存储的cookie,模拟向其他网站发起“合法”请求。需要注意下面两点

  • 不使用GET方法更新数据
  • 对于POST方法,通过后台生成随机的csrf_token注入到form<input type="hidden">标签中预防

总而言之,不信任用户的所有输入,对输入做处理,避免SQL注入和XSS攻击。


96. CSS属性继承

无继承性:

  • display
  • 文本属性:vertical-align/text-shadow/text-decoration
  • 盒模型属性
  • 背景相关属性
  • 定位属性:float clear top/left/right/bottom z-index
  • 轮廓内容:content outline

有继承性:

  • 字体属性
  • 文本属性:text-align/line-height/word-spacing/letter-spacing/color
  • 可见性:visibility
  • 表格列表:list-style
  • 光标:cursor

行内元素可继承:字体属性,除了text-indent/text-align
块元素可继承:text-align/text-ident

97. 移动端实现0.5px的border

  • 结合:before:after通过transform-originscale实现
  • 利用渐变background-image: linear-gradient
  • 直接使用backgroun-image

98. 随机打乱一个数组

Fisher-Yates shuffle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function shuffle(array) {
let counter = array.length;

// While there are elements in the array
while (counter > 0) {
// Pick a random index
let index = Math.floor(Math.random() * counter);

// Decrease counter by 1
counter--;

// And swap the last element with it
let temp = array[counter];
array[counter] = array[index];
array[index] = temp;
}

return array;
}

99. 移动端fixed定位bug

iOS中,在软键盘唤起后,fixed定位元素会失效,变为absolute定位。解决方案:主体部分设为height: 100%; overflow-y: scroll,通过absolute定位实现。

100. JS如何获知当前页面是否来自缓存

配合后台:后台传递时间戳到当前页面或cookie
无后台:通过xhr放送HEAD请求,得到返回的status code

101. 重复打印字符串

幂次叠加,substr切割字符串。

102. 正则匹配中?:?=的意思

  • ?: 非捕获匹配分组,匹配并出现在匹配结果中,但不作为子匹配返回
  • ?= 前瞻匹配,不出现在匹配结果中

更多参见问题16

下面的大部分问题来自Github的这个仓库,排名不分先后

18. Vue和React框架的对比

先说相同之处,Vue和React都是优秀的MVVM框架。具有响应式设计的种种特点,因此数据驱动是使用这两种框架不能忘的出发点。正如上一个问题提到的那样,开发者只需处理好数据,让框架去处理易错的UI。同时,组件化前端开发流程也是它们强调的一点,用组件化之名行代码重用之实,通过组件积木去构建整个页面。最后,它们不约而同地使用虚拟DOM树(vDOM)(Vue是在2.0引入的)抽象页面的节点,通过优化的diff算法减少频繁的DOM操作,减少交互的响应时间。另外,在最新的版本中,Vue和React的源码分别通过FlowTypeScript加入类型约束,增强可读性和健壮性。

两者同时也有MVVM框架共同的短板,把过多的渲染放在客户端,在页面元素复杂时,会加大交互的延迟。同时会影响首屏时间。

再说它们的不同之处,不同之处基本都在使用细节上。

  • Vue通常把网页模板写在template属性中,默认不支持JSX语法(可以通过插件支持)。React中则是写在render里
  • Vue糅合了Angular 1.x的特点,通过一些内置的自定义标签属性减少开发者的代码量。React则更加纯粹,少了这些预设的框架
  • Vue使用Object.defineProperty实现数据绑定,React通过setState显式更新依赖。前者更加酷和自然,但是在编程风格不好时,容易出现难以debug的错误。React推荐immutable Object,每次更新时使用新对象更新自身state,出错的概率更低。
  • Vue和React在组件生命周期上有些细微的差别。Vue的生命周期更加简明,在create,mount,update,destroy前后设置钩子函数,React在state改变前后也会有钩子函数
  • Vue的脚手架使用起来较React更加顺手(个人感觉),单文件.vue的组织方式,内部支持模板语言jade、ejs,预处理语言coffeescript,sass等。React的构建方式则更加自由。
  • React的社区较之Vue更为活跃些,流行的库要更多,脚手架中的库更多是爱好者自己开发的。Vue和React生态圈中都有路由和状态管理器的工具,其中Vue的都是官方开发的。

19. TCP三次握手,四次挥手过程

过程参考下图。

因为断开连接时数据可能并未传输完成,所以挥手时要多一步。

20. CSS预处理语言

Sass和Less。Sass支持变量的定义和使用,有语法控制结构,同时支持@mixin定义mixin和@function定义函数。更多介绍可以看我之前的一段笔记

less语法使用上和Sass相近,采用JavaScript实现。支持本地和在线解析。本人没有用过,更多特性参考官网

21. JS里的错误和异常处理

try catch语句块捕获错误,catch块接受一个参数作为错误对象。对象的message属性会给出错误的详细信息。catch后还可以接上finally关键字,finally语句块在错误处理后必定执行。throw语句可以抛出错误。

22. 闭包

闭包是JavaScript中比较有特色的概念。它和JS中的作用域链(见问题4)概念密切相关。闭包发生在函数中定义的函数,在外层函数退出后,其作用域环境通过作用域链仍然保存在存活的内部环境中。利用这种特点,可以实现诸如状态保存,封装等特殊性质。

值得注意的是,内层函数的不合理操作会导致内存泄漏。且大量使用闭包会导致性能问题。不要过度依赖闭包。

23. 列举一些RESTful操作名

GET/POST/DELETE/UPDATE/PUT等

24. 列举一些CSS中的长度单位

固定长度:

  • px 像素点
  • mm 毫米
  • cm 厘米
  • in 英寸
  • pt 磅(1/72英寸)
  • pc 活字(1/6英寸)

相对长度:

  • em 以字体大小(font-size)为单位
  • ex 以小写字母大小为单位
  • rem 以根元素(默认为<html>)的字体大小为单位,用于自适应布局
  • vh 视口高度的1/100
  • vw 视口宽度的1/100
  • vmin 视口宽高较小值的1/100
  • vmax 视口宽高较大值的1/100

25. 前端职责

狭义地来说是实现UI设计师的设计稿和UE、UX的交互细节。宏观来说,是借助浏览器在技术上处理和用户交互的所有环节。近些年来,借助native的帮助,前端还可以实现后台的业务部分。

26. 职业规划

27. 获取新鲜资讯的方式

28. 前端模块化

模块化规范包括CommonJS,CMD(SeaJS),AMD(RequireJS)等。我此前做过一个小型的笔记

29. position类型

static, relative, absolute, fix四种。

  • static 正常文档流
  • relative 正常文档流,指定top, left等CSS属性相对原位置移动
  • absolute 脱离文档流,相对上一个非static元素定位
  • fix 脱离文档流,相对html定位

通常建议使用正常文档流减少潜在bug。绘制复杂动画时,建议使用脱离文档流的布局。

30. 盒模型

盒模型意为所有渲染的元素都是一个个的矩形。矩形区域内包含margin, border, paddingcontent4层。CSS属性中只有两种盒模型:border-boxcontent-box。后者是默认值。可以通过box-sizing属性设置。

31. 使用原生JS发送AJAX

现代浏览器中都通过XMLHttpRquest对象实现Ajax请求。古老的IE浏览器会有不同的实现方法(目前已经很少见了),如:

1
2
new ActiveXObject("Microsoft.XMLHTTP");
new ActiveXObject("Msxml2.XMLHTTP.6.0");

通常使用XMLHttpRequest发送GET的方法如下:

1
2
3
var xhr = new XMLHttpRequest();
xhr.open("GET", "demo.php?id=1");
xhr.send()

发送POST和其他复杂请求时,需要设置request header,在需要接受AJAX返回时,还可以监听readyState的change事件
,大致像下面这样:

1
2
3
4
5
6
7
8
var xhr = new XMLHttpRequest();
xhr.open("POST", "ajax_post.php");
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState === 4 && xhr.status === 200) {
console.log("ok" + xhr.responseText);
}
}

XMLHttpRequest2中又引入了进度,跨域,中止等新特性。

另外,使用新的Fetch API也可以完成Ajax请求。Fetch提出的目标是提供访问和操纵HTTP的接口,异步的获取网络资源。它和XMLHttpRequst的区别是:

  • 即使响应是404或500,返回的Promise也会正常解决
  • 默认情况下,fetch在服务端不会发送或接受任何cookies

在Ajax需要跨域时,最常用的方法是使用jsonp的形式实现。不过目前通过XMLHttpRequest2或fetch也都能完成跨域请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function jsonp(url, success) {
var ud = '_' + +new Date,
script = document.createElement('script'),
head = document.getElementsByTagName('head')[0]
|| document.documentElement;
window[ud] = function(data) {
head.removeChild(script);
success && success(data);
};
script.src = url.replace('callback=?', 'callback=' + ud);
head.appendChild(script);
}
jsonp('http://soundcloud.com/oembed?url=http%3A//soundcloud.com/forss/flickermood&format=js&callback=?', function(data){
console.log(data);
})

31. 如何加快访问速度

这个问题实际上很大,可以从各个方面去优化

  • 压缩并打包资源文件
  • 使用CDN存储资源文件
  • 设置缓存
  • CSS sprites
  • 图片压缩
  • 图片懒加载
  • 合理的DOM层级设置
  • <script>标签设置deferasync或动态异步加载
  • 一般情况下,CSS在前,JS在后
  • 使用MVVM框架时,使用服务端渲染或预渲染

32. 前端的未来发展

随着大前端的风吹来,前端工作在横向和纵向都获得了更多的机会。横向上,由PC端到移动端甚至有界面展示的智能终端。不过后两者需要记住native库的帮助。纵向上,在NodeJS的帮助下,后台的部分业务功能抽离出来交由前端完成,前端对界面有完整的控制,数据通过接口的形式向后台索取。现在看来,身为一个前端工程师,不仅要对HTML,CSS,JavaScript老三样了如指掌,对Android或iOS也渐渐有了些要求。

33. CSS selector的优先级顺序

how browsers work中有介绍。根据CSS3 selectors specificity中的定义,一个选择器的优先级计算如下

  1. 如果声明来自于style属性,而不是带有选择器的规则,则记为1,否则记为0 (= a)
  2. 记为选择器中ID属性的个数 (= b)
    3.记为选择器中其他属性和伪类的个数 (= c)
  3. 记为选择器中元素名称和伪元素的个数 (= d)

将四个数字按a-b-c-d这样连接起来(位于大数进制的数字系统中),构成特异性。所使用的进制取决于上述类别中的最高计数。最终决定优先级顺序。简而言之就是,style > id > class > tag > pseudo class,统计情况下看个数,有!important时,以!important为准。

34. 连等的赋值顺序

问题是,有这样一段代码,问原理是什么:

1
2
3
4
var foo = { n: 1 };
var bar = foo;
foo.x = foo = { n: 2 };
console.log(foo.x); // undefined

我们来细化一下过程,首先我们要明确JavaScript中对象是引用类型:

  • 第一句里,首先创建了一个字面量对象,并将foo指向之
  • 第二句里,将foo赋值给bar,即bar也指向{ n: 1 }这个对象
  • 第三句里,又创建了一个{ n: 2 }的对象,首先寻找foo指向对象中是否有x属性,没有时则新建一个x属性指向这个字面量对象,接着改变foo存储的地址,指向这个新的对象。
  • 第四句里,由于新的对象没有x属性,foo.x将返回undefined

这时,如果我们console.log(bar)结果将是{x: {n: 2}, n: 1}`。

35. JavaScript的hoist行为

hoist特性又叫变量声明提升。是JavaScript中比较有特点的特性。意为将作用域中所有变量(包括函数)声明提升到语句的开头。如下面的语句

1
2
3
4
5
6
7
a = 1;
fun(a);

var a = 0;
function fun(num) {
return num + 1;
}

等同于

1
2
3
4
5
6
7
8
var a;
function fun(num) {
return num + 1;
}
a = 1;
fun(a);

a = 0;

需要注意的是当通过函数表达式声明函数时,会提示fun未定义。因为,此时的语句等同于

1
2
3
4
5
6
7
8
9
var a;
var fun;
a = 1;
fun(a);

a = 0;
fun = function(num) {
return num + 1;
}

最后,最佳的风格是所有的变量先声明再使用。这也是JSLint和JSHint等linter工具推荐的。

36. 优雅降级和渐进增强

又名graceful degration和progressive enhancement。是两种开发的思路。前者意为针对最高级的浏览器设计开发,再保证向下兼容;后者意为针对低版本浏览器设计,保证基础性能,再追对现代浏览器追加效果,提高用户体验。

37. 优化网页资源

  1. 使用CDN
  2. 分布存放
  3. CSS sprites
  4. disable etag

38. 浏览器一次可以从同一domain加载多少资源

2~8,因浏览器而异。这里有个非常全的表。

39. 轮播图设计思路

可以用display实现,配合渐变效果。需要看到幻灯片移动时,可以结合transform和scale实现,配合overflow: hidden

40. CSS3的部分新特性

  • border: border-radiusborder-image
  • background: background-sizebackground-origin
  • text: font-facetext-overflowtext-shadowword-breakword-wrap
  • transform: translaterotateskewscale
  • transition: transitionanimation
  • other: box-shadow

41. ARIA

ARIA全称Accessible Rich Internet Application。主旨是提升网页易用性,方便有阅读障碍的人使用。比较常用的属性有

  • role 当前元素的作用,
  • <label>, aria-label 元素名
  • aria-hidden 是否隐藏

42. CSS animation和JavaScript animation

前者方便简单,通过keyframe就可以画出动画,且浏览器会做一些优化,因此性能也比较好。后者更加灵活,可以暂停和反转,且支持交互性。更详细的分析参考这里

43. doctype是什么

doctype出现在HTML4.1。用于规范HTML和XML文档格式,在推出时,HTML有3种标准可以选择:strict, transitional, frameset。

1
2
3
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Frameset//EN" "http://www.w3.org/TR/html4/frameset.dtd">

在HTML5中,只有一种doctype可以选择,那就是html:

1
<!DOCTYPE html>

44. standard mode和quirks mode

分别是标准模式和怪异模式。由于历史原因,为了兼容标准建立前就已存在的古老网站,浏览器存在着两种解析网页的模式。在怪异模式下,排版会模拟Navigator 4与Internet Explorer 5的非标准行为。为了支持在网络标准被广泛采用前,就已经建好的网站,这么做是必要的。在标准模式下,行为即(但愿如此)由HTML与CSS的规范描述的行为。在<!DOCTYPE>中指定html将自动启用标准模式。这里有更多介绍。

45. XHTML和HTML

XHTML伴随HTML4.01一同提出,使用更加严谨的语法。MIMEtype为application/xhtml+xml,比如:html元素需要有xml相关属性,元素名必须是小写字母,元素属性用"包围不能为空值,在内容里不能有&, 需要转义,包括其他特殊字符<>,空元素以/>结尾。由于语法过于严苛,使用的人不多。未推出的XHTML1.1便被html5取代。

下面的大部分问题来自Github的这个仓库,排名不分先后

1. JS中如何定义自定义事件,实现订阅/发布模式

明确需求:可以通过onemit绑定和触发事件。

方案:创建全局事件管理器events,构建事件名和回调函数数组的键值对。onemit分别写和读events。大概像下面这样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var EventUtil = {
// 全局事件管理
var events = {},
// 注册事件
on = function (type, handler) {
if (events[type]) {
events[type].push(handler);
} else {
events[type] = [handler];
}
},
// 触发事件
emit = function (type) {
if (!events[type]) return;
for (var i = 0, len = events[type].length; i < len; i++) {
events[type][i];
}
};
};

当使用Object.assign实现继承时,会出现events共享的问题。可以通过在第一次调用on时,通过Object.defineProperty的方式创建避免共享。

2. js中的this

首先,this永远是对象。

  • 全局上下文内,this全局对象
  • 函数上下文内,根据调用场景分情况讨论
    • 直接调用:全局对象
    • 通过对象的方法调用:调用方法的对象
    • 构造函数中:即将被创建的对象,有return语句时,以return的返回值为准
    • call和apply:传入的第一个值
    • bind方法:永久绑定到第一个参数

3. js跨域问题和解决方案

跨域(Cross-domain)是网景最初基于安全性考虑提出的策略。意为不同域名不同协议不同端口间的Ajax通信是被禁止的。根据使用需求,可以分为跨站请求资源和跨页面共享资源(我自己发明的说法)

跨站请求资源

  • jsonp(json with padding)跨域,利用了<script>标签的可跨域完成,自己写一遍就能搞懂
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    function getJSONP(url, success) {

    var ud = '_' + +new Date,
    script = document.createElement('script'),
    head = document.getElementsByTagName('head')[0]
    || document.documentElement;

    window[ud] = function(data) {
    head.removeChild(script);
    success && success(data);
    };

    script.src = url.replace('callback=?', 'callback=' + ud);
    head.appendChild(script);
    }
  • CORS,使用CORS进行跨域请求时,在现代浏览器中已经可以像普通Ajax请求那样使用XMLHttpRequest即可,可以参考这个。需要后台服务器支持
  • 后台反向代理,需要一台中转的服务器
  • 建立websocket通信,需要后台服务器支持

跨页面共享资源,结合<iframe>有以下几种方案

  • 修改document.domain,使两个页面位于同一域名下,注意只能从精确->模糊修改域名
  • 通过window.name传递消息,利用了iframe location变化后,window.name不变的特点
  • location.hash
  • html5中的postMessage API在不同window间传递消息

这里附上一个链接.

4. js的作用域链

这是JavaScript最有特点同时也是最基础的内涵之一。红宝书和犀牛书都做了详尽和透彻的解释。这个问题理解了,什么是闭包就能很好地理解了。

执行环境是JavaScript中最为重要的一个概念。执行环境定义了变量或函数有权访问的其他数据,决定了它们各自的行为。每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和函数都保存在这个对象中。虽然我们便习得代码无法访问这个对象,但解析器在处理数据时会在后台使用它。

全局执行环境是最外围的一个执行环境。…。某个执行环境中的所有代码执行完毕后,该环境被销毁,保存在其中的所有变量和函数定义也随之销毁(全局环境知道应用程序退出时才会销毁)
每个函数都有自己的执行环境。当执行流进入一个函数时,函数的环境就会被推入一个环境栈中。而在函数执行后,栈将其环境弹出,把控制权返回给之前的执行环境。ECMAScript程序中的执行流正是有这个方便的机制控制着。

当代码在一个环境中执行时,会创建变量对象的一个作用域链(scope chain)。作用域链的用途,是保证对执行环境有权访问的所有变量和函数的有序访问。作用域链的前端,时钟都是当前执行的代码所在环境的变量对象。如果这个环境是函数,则将其活动对象做为变量对象。活动对象在最开始时只包含一个变量,即arguments对象(这个对象在全局环境中是不存在的)。作用域链中的下一个变量对象来自包含(外部)环境,而在下一个变量对象则来自下一个包含环境。这样,一直延续到全局执行环境;全局执行环境的变量对象始终都是作用域链中最后一个对象。

5. Function.callFunction.apply的区别

callapply同为改变this的方法,前者一个一个接收输入参数,后者以数组的形式接收。

6. 浏览器渲染页面的原理

可以参考经典的文章how browsers work,或者中译版.

7. 列举一些HTML5的改进

可以参考MDN给出的summary。比如:

  • 语义化,语义化标签<header>, <article>,语义化元素<figure>, <time>等和新的多媒体标签<audio>, <video>
  • 网络通信,Websocket,WebRTC
  • 图像,Canvas和WebGL
  • 离线存储,Storage接口和IndexDB
  • 性能,Web Worker,XMLHttpRequest2(支持进度等新特性),History API,Fullscreen,PointerLock,requestAnimationFrame等
  • CSS,CSS3的特性,有些甚至演进到了Level 4

8. HTML5中的定位API

Geolocation API,新的API,红宝书中有提到。通过navigator.geolocation对象实现,允许用户提供自己的所在地理位置。需要用户确认同意才可使用。最常用的方法是getCurrentPosition()。这个方法接受三个参数——成功回调、可选的失败回调、可选的选项。

类似的不常见的API还有Battery APIFile APIperformance等。

9. 一些前端框架的双向绑定原理

不是所有框架都提倡双向绑定。有的框架如Angular使用数据双向绑定,适合于表单很多的站点,React和Vue这样的使用的是单向绑定。在单向绑定背景下,可以通过addEventListener实现双向绑定。

实现原理上分为几种:

  • 发布-订阅模式,显式地绑定事件和监听函数。backbone就是这么做的,显式地通过Model.set修改数据触发change事件来更新视图。React也是通过setState显式地触发虚拟DOM树更新和重新渲染的。
  • 脏检查(digest cycle),通过特定事件触发脏检查。脏检查即一种不关心你如何以及何时改变的数据,只关心在特定的检查阶段数据是否改变的数据监听技术。过程大致是$update或其他手段触发digest阶段,遍历通过$watch绑定的watcher。对比值是否改变触发更新。优点是无需知道更改数据的方式,可以统一更新view,缺点是watcher较多时会有严重的性能问题。
  • 数据劫持Object.defineProperty,Vue使用这种方式实现隐式的绑定(当然在具体实现中复杂了许多)。这么做的问题是版本只支持到IE9+,且在数组更新时有所局限。

10. webpack的配置文件写法

除了常用的entry, output, module, plugins外,webpack的使用方法实在太多,建议去官网查看完整的配置信息。

11. node文件和网络API

文件操作上,常用的有fs.readFileSyncfs.writeFileSync,或通过流的方式使用fs.createReadStreamfs.createWriteStream。还有pipe将流连接在一起。除此之外,pathjoinnormalize常用在处理文件路径。

和网络操作相关的包包括http, https, url, querystring, zlib等。其中前两个包更为常用,尤其是http.createServer方法。

另外,在进程上有process, child_process等包。这里 有一篇文章做了比较详细的介绍。当然,有空最好还是去官方文档.

12. 和@import的区别

它们的最常见的使用方式都是引入CSS文件到html中。它们的区别在于

  • link是XHTML标签,除了加载CSS外,还可以引入RSS等其他资源;@import属于CSS范畴,只能加载CSS。
  • link引用CSS时,在页面载入时同时加载;@import需要页面网页完全载入以后加载。
  • link是XHTML标签,无兼容问题;@import是在CSS2.1提出的,低版本的浏览器不支持。
  • 由于link是标签,可以通过JavaScript控制来改变样式,后者不行。

13. cookie,localStorage和sessionStorage的区别和联系

cookie设计的初衷是用来为无状态的http访问添加用户状态消息的。大小4KB以下,会携带在请求头中。大多包含敏感信息,会和服务器端的session配合使用。

Storage API是HTML5的新API。又可以细分为localStorage和sessionStorage。它们一般只存储在客户端,用来缓存用户非敏感数据,大小因浏览器而异,容量约可达到5MB。sessionStorage在浏览器关闭后清除,localStorage则在超过时限或手动clear后清除。

cookie中的内容很少变化,且最好秘文储存,并通过HttpOnly添加限制(后台修改set-cookie头)。Storage则很可能会频繁读写。

14. HTTP状态码

根据状态码开头的数字确定状态码类型。下面列举一些常用的。

1xx 信息:这一类型的状态码,代表请求已被接受,需要继续处理。这类响应是临时响应。

  • 100 继续:客户端应当继续发送请求。
  • 101 切换协议:将通过Upgrade消息头通知客户端采用不同的协议来完成这个请求。

2xx 成功:这一类型的状态码,代表请求已成功被服务器接收、理解、并接受。

  • 200 OK:请求已成功,请求所希望的响应头或数据体将随此响应返回
  • 201 已创建:请求已经被实现,而且有一个新的资源已经依据请求的需要而创建,且其URI已经随Location头信息返回
  • 202 已接受:服务器已接受请求,但尚未处理
  • 204 No Content:服务器成功处理了请求,但不需要返回任何实体内容,用户浏览器应保留发送了该请求的页面
  • 205 Reset Content:和204的唯一不同是返回此状态码的响应要求请求者重置文档视图
  • 206 服务器已经成功处理了部分GET请求。该请求必须包含Range头信息来指示客户端希望得到的内容范围,多用于下载工具

3xx 重定向:这类状态码代表需要客户端采取进一步的操作才能完成请求。通常,这些状态码用来重定向,后续的请求地址(重定向目标)在本次响应的Location域中指明。

  • 300 多选择:被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。
  • 301 永久移动:被请求的资源已永久移动到新位置
  • 302 临时移动:请求的资源现在临时从不同的URI响应请求
  • 303 重定向:对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用GET的方式访问那个资源
  • 304 如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变
  • 305 使用中介:被请求的资源必须通过指定的代理才能被访问

4xx 客户端错误:代表了客户端看起来可能发生了错误,妨碍了服务器的处理

  • 400 无法理解的请求:由于包含语法错误,当前请求无法被服务器理解
  • 401 需要验证:当前请求需要用户验证。响应必须包含一个适用于被请求资源的WWW-Authenticate信息头用以询问用户信息。
  • 403 禁止访问:服务器已经理解请求,但是拒绝执行它
  • 404 未找到:请求所希望得到的资源未被在服务器上发现
  • 405 方法不允许:请求行中指定的请求方法不能被用于请求相应的资源,响应中必须返回一个Allow头信息用以表示出当前资源能够接受的请求方法的列表
  • 406 头部不对:请求的资源的内容特性无法满足请求头中的条件
  • 408 请求超时:客户端没有在服务器预备等待的时间内完成一个请求的发送
  • 411 需要指定长度:服务器拒绝在没有定义Content-Length头的情况下接受请求
  • 413 请求实体太长
  • 414 URI太长

5xx 服务器错误:代表了服务器在处理请求的过程中有错误或者异常状态发生

  • 500 内部错误:一般来说,这个问题会在服务器的代码出错时出现
  • 501 未实现:服务器不支持当前请求所需要的某个功能
  • 502 Bad GateWay:作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应
  • 503 服务不可达:由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。
  • 504 网关超时:作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器或者辅助服务器收到响应
  • 505 HTTP协议版本不正确

15. URL去参数

location.search, location.href, location.origin分别代表url中的querystring,完整url和域名。再结合location.pathname, location.portlocation.protocol可以得到任意想要的URL参数。

另外,新的APIURLSearchParams中有些方法可以对querystring做方便的增删改查的操作。

  • append增加一个检索参数
  • delete删除一个检索参数
  • get获取检索参数的第一个值
  • getAll获取检索参数的所有值
  • has检查是否存在某检索参数
  • set设置一个检索参数的新值,会覆盖原值
  • keysvalues分别返回键和值组成的数组

16. js中的正则匹配

js中的正则匹配和Perl的正则匹配规则基本类似。在js中,使用一个正则表达式字面量,由包含在斜杠之间的模式组成。正则表达式同时也是RegExp对象。除了简单模式外,考察对正则表达式的熟悉在它的特殊字符使用上。

一些常见的特殊字符:

  • \ 用于转义

  • ^ 用于匹配开始或表示一个反向字符集(如[^xyz]

  • $ 用于匹配结尾

  • * 匹配前一个表达式0或多次 = {0,}

  • + 匹配前一个表达式1或多次 = {1,}

  • ? 匹配0或1次 = {0,1};紧跟量词后使匹配非贪婪

  • . 匹配除换行符外任何单字符

  • (x) 捕获匹配,会包括在最后结果中,也可以通过$1, $n来访问

  • (?:x) 非捕获分组,匹配但不捕获

  • x(?=y) 断言匹配,捕获后跟y的x

  • x|y 匹配x或y

  • {n} 量词,匹配n次,还有{n,m}和{n,}的用法

  • [xyz] 字符集,可以使用-连接,如[x-z]

  • \d 一个数字

  • \D 一个非数字

  • \s 一个空白字符,包含空格,制表符,分页符,换行符

  • \S 一个非空白字符

  • \w 一个单字字符,等价于[A-Za-z0-9_]

  • \W 一个非单字字符

另外,正则表达式还有几个可选参数辅助搜索类型

  • g 全局搜索
  • i 不区分大小写
  • m 多行搜索
  • y 粘性搜索

有一些方法用于和正则表达式相关

  • exec 在字符串中执行匹配,返回匹配结果
  • test 测试是否能匹配RegExp,返回true或false
  • match 对字符串执行查找匹配的String方法,返回匹配结果
  • search 在字符串中测试匹配,返回位置索引或-1
  • replace 在字符串中执行查找匹配,并使用替换字符串替换匹配的子字符串
  • split 使用一个正则表达式或字符串分割一个字符串,并储存在数组中

常见的考法有,书写一个邮箱或手机号的正则表达式:

  • 邮箱 /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
  • 手机号 /^0*(13|15|18|14|17)(\d{9}|\d-\d{3}-\d{5}|\d-\d{4}-\d{4}|\d{2}-\d{3}-\d{4}|\d{2}-\d{4}-\d{3})$/

17. MVC和MVVM框架

框架模式不是一门写代码的学问,而是一门管理与组织代码的学问。其本质是一种软件开发的模型。与设计模式不同,设计模式是在解决某类特定问题时总结抽象出的公共方法,是方法论的范畴,一种框架模式往往使用了多种设计模式,且和技术栈有耦合的关系。

视图(View)从本质上讲是数据在图像上的一种体现和映射。用户在操作图像时可以达到操作数据的目的,在数据更改后,需要重新将数据映射到视图上。这实际上就是MVC的出发点。

  • View: 放置视图相关的代码,原则上里面不应该有任何业务逻辑。
  • Controller: 放置视图与模型之间的映射,原则上这里应该很薄,他只放一些事件绑定相关的代码(router),但并不实现真正的功能,他只是一个桥梁。
  • Model: 这里的model不是说实体类,它是主要实现业务逻辑的地方。

开发流程是先创建视图组件,再将之关联到Model上,通过View修改Model中的值时,Model会触发绑定在之上的所有View的更新。Backbone是个典型的例子。这么做部分分离了视图和逻辑。但是,在情况复杂时,Model的代码量将会大大膨胀。

MVP因此而生,其中Presenter(分发器)代替了原来的Controller,分担了Model的部分功能。针对上面的问题,Presetner隔断了Model和View,当M改变时,会通知P去更新视图。业务逻辑和绑定逻辑从V和M中分离出来到P中。使得MVP三方分工更加鲜明。绝大多数的PHP框架都是MVP类型的。

MVVM是Model-View-ViewModel的缩写。在MVVM中,View和ViewModel是双向或单向数据绑定的关系。当ViewModel反应了Model中的数据模型,并绑定到视图属性上,反过来,视图属性变化后也会通过ViewModel影响Model。React,Vue这些流行的前端框架都是MVVM类型的。

不管是MVC还是MVP或MVVM,他们都是数据驱动的。核心上基于M推送消息,V或P来订阅这个模型。使用者需要维护的不再是UI树,而是抽象的数据。当UI的状态一旦多起来,这种框架模式的优势就很明显了。

本文基于Express 4.15.2

我之前的Express学习笔记还在这里

Express常被用来进行Node.js后台的快速搭建。它仅仅对Node.js进行了简单的封装,结合中间件等很自然和好用的概念,很容易上手和学习。Express的API主要包括Application,Request,Response,Router几个部分,这是Express代码主要实现的部分。在我看来,Express贵在它的中间件,它提供了足够自由的空间但也做出规范,提供req, res, next, err给中间件操作。它的生态系统也是围绕这个展开的。

创建服务器

让我们从头回忆,用Express创建一个简单的服务器。像下面这样。

1
2
3
4
5
6
7
8
9
10
var express = require('express')
var app = express()

app.get('/', function (req, res) {
res.send('Hello World!')
})

app.listen(3000, function () {
console.log('Hello world!')
})

先通过构造函数创建一个Express应用(Application)。接着为它指定路由规则。最后通过app.listen()的方式启动服务器。对比下Node.js原生的写法:

1
2
3
4
5
6
var http = require('http');

var server = http.createServer(function(request, response) {
res.write('Hello world');
res.end();
}).listen(3000);

那么Express的app究竟是什么,路由中间件又是如何绑定上去的。这些问题需要通过源码来解答。

代码结构

Express代码整体设计并不复杂(相对于Vue这样的复杂设计),比较容易看懂,一些常见的功能已经事先写成依赖包抽取出来,如debug(打印debug信息)和deprecate(显示API已废弃)等。Express的源码部分位于lib/路径下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
...
- lib/
- middleware/
- init.js
- query.js
- router/
- index.js
- layer.js
- route.js
- application.js
- express.js
- request.js
- response.js
- utils.js
- view.js
...
- index.js
...
1
2
...
module.exports = require('./lib/express');

根目录下的index.js是整个项目的入口,所做的工作只是引入lib/中的express.jslib/目录下middleware目录下放置了内置的中间件,router中放置中间件的功能实现。下面的几个文件中

  • application.js 应用的定义,app对象的API
  • express.js,对app,router等功能的封装
  • request.js和response.js是对http中res以及req的封装和增强
  • utils.js 常用工具函数的封装
  • view.js 建立拓展名和渲染引擎的联系

Application

express.jsApplication.js大致告诉我们了expressapp究竟为何物。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//express.js
exports = module.exports = createApplication;

function createApplication() {
// 创建一个app
var app = function(req, res, next) {
app.handle(req, res, next);
};

//继承EventEmitter和Application.js中定义的app对象
mixin(app, EventEmitter.prototype, false);
mixin(app, proto, false);

...
// 初始化app
app.init();
return app;
}

/*
* 一些暴露公用接口的操作
*/

/*
* 告知一些方法已废弃
*/

上面的mixin引入自merge-description,功能非常简单——通过描述符融合两个对象并返回,源码也很简单,主要由getOwnPropertyDescriptordefineProperty方法实现,感兴趣的可以一看。

app通过mixin继承了两个预定义对象,其中EventEmitter来自Node.js的API,继承后app将获得事件发布订阅功能。protoApplication.js导出定义。其中定义了app.listen()方法。

1
2
3
4
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};

综上就很明白了

  • 首先,express()返回的app其实是一个函数,可以接受req, res以及定义在Express中的next
  • 之后,app.listen()方法实际上执行了http.createServer(app).listen()

总结,**express()是个工厂函数,负责生产作为http.createServer(app)的参数**。之后,让我们继续深入看看Application.js中的代码。

app的工作主要是在Application.js中进行的。下面介绍一些它的属性和相关方法

  • cacheObject,缓存视图信息
  • engineObject,视图渲染引擎
    • engine(ext, fn),绑定拓展名和渲染方法,返回app自身用于链式调用
  • settings,app的设置信息,可以通过setget方法设置和获取。
    • get(setting)(或set(setting))获取配置值,这里是完整的配置表
    • set(setting, value)设置配置值
    • enabled(setting)disabled(setting)语法糖,获取Boolean类型配置值
    • enable(setting)disable(setting)语法糖,设置Boolean类型配置值
  • locals在app生命周期内存储用户信息
  • mountpath 顶级app挂载路径
    • path()返回app的挂载路径
    • use(fn)挂载中间件时可以指定第一个参数为挂载路径
  • router 该路由中间件属性在4.x中已废弃,由route(path)方法替代
  • _routerlazyrouter内部方法中加载,Router对象,中间件的容器。详细介绍在下文的Router一节中。

除了这些属性外,还有一些常见或内部使用的方法

  • defaultConfiguration初始化默认配置
  • lazyrouter自身路由的懒加载,原因写在了源码注释
  • handle(req, res, cb)最关键的app方法,用于分发请求完成路由功能。但它实际上只是对router.handle做了简单的封装。并在最后通过finalhandler来做兜底。finalhandler是个很轻量级的依赖包,用于生成404页面和记录错误。详细文档见github.
  • use(fn)最关键的app方法,用于添加中间件,主要逻辑是将arguments中的pathfn列表拆开来,再调用router的use方法注册
  • route(path)调用router的route方法注册一个路由中间件
  • param(name, fn)在自身的router对象中注册参数匹配回调
  • VERB(path, fn)为HTTP动词绑定路径和处理函数,主要功能也是由router代劳。有意思的是,HTTP动词是通过methods这个依赖包来返回的,这个包原理也非常简单——http.METHODS
  • all(path)用来匹配任意一个HTTP动词,其余功能和上面相同,实现上即将该路由中间件的所有动词都绑定上处理函数。
  • del 已废弃
  • render(name, options, cb)调用渲染引擎渲染模板
  • listen()即上文提到的方法,对http.listen的封装

中间件

中间件是Express设计的最精妙的地方,也是工作量最大的地方。之前Express利用了Connect来做这项工作。在当前版本中放在了router目录下去实现。整体来看,一个Express应用就是一系列的中间件首尾相接组成的。那么中间件是什么呢?用官网的话说就是“Middleware functions are functions that have access to the request object (req), the response object (res), and the next function in the application’s request-response cycle.”从code的角度来看,就是下面这样

1
2
3
4
var myMiddleware = function(req, res, next){
// do something
next();
};

如果是错误处理的中间件,需要将err传入为第一个参数

1
2
3
4
var myErrorMiddleware = function(req, res, next){
// do something
next();
};

所以,一个请求进入Express后,处理的流程大致是下面这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13

---------------
| middleware1 |
---------------

---------------
| ... ... ... |
---------------

---------------
| middlewareN |
---------------

其中每个中间件能拿到reqres抑或err,在完成自身工作后调用next()执行下一个中间件。那么这些middleware是怎么放置在这条链上,请求又是如何通过next()一步步向下传递的呢?

中间件分两种:普通中间件和路由中间件。它们都通过handle方法处理;前者通过use方法注册,后者通过VERB(或all)方法注册。其中

  • 前者匹配所有以path开始的路径,而后者会精确匹配path路径;
  • 前者对于请求的方法不做要求,而后者只能处理指定的请求。
1
2
3
4
5
6
7
app.use('/user', function(req, res, next) {
// do something
});

app.get('/user', function(req, res, next) {
// do something
});

app层面

在app层面,即Application.js中,是由app.use(fn), app.VERB(或app.all)和app.handle(req, res, cb)完成的。而它们只是router的同名方法的简单封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
app.use = function use(fn) {
var offset = 0;
var path = '/';

// 由于这个API支持多参数,需要先判断第一个参数是否为路径
// 并通过offset存储结果
if (typeof fn !== 'function') {
var arg = fn;

while (Array.isArray(arg) && arg.length !== 0) {
arg = arg[0];
}

// first arg is the path
if (typeof arg !== 'function') {
offset = 1;
path = fn;
}
}

// 根据offset获取中间件函数
var fns = flatten(slice.call(arguments, offset));

...

// 调用lazyrouter
...

fns.forEach(function (fn) {
// 原生函数时,调用router.use注册即可
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}

...

// 中间件为router或app时,handle方法才是我们需要的
// 储存req和res的app属性到处理它的app
router.use(path, function mounted_app(req, res, next) {
var orig = req.app;
fn.handle(req, res, function (err) {
setPrototypeOf(req, orig.request)
setPrototypeOf(res, orig.response)
next(err);
});
});

...
}, this);

return this;
};

其中flatten用于将多层嵌套数组扁平化为1层。可见到,app的use方法在做了预处理工作后,调用router的use完成注册工作。

需要路由中间件时,我们需要使用动词对应的方法(或all)去注册。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
methods.forEach(function(method){
// 根据形参个数避免app.get带来的歧义
app[method] = function(path){
if (method === 'get' && arguments.length === 1) {
// app.get(setting)
return this.set(path);
}

this.lazyrouter();

// 调用router的route方法构造一个路由中间件
var route = this._router.route(path);
// 为路由中间件特定方法指定处理函数
route[method].apply(route, slice.call(arguments, 1));
return this;
};
});

app的handle方法就比较简单了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
app.handle = function handle(req, res, callback) {
var router = this._router;

// 创建兜底的final handler
var done = callback || finalhandler(req, res, {
env: this.get('env'),
onerror: logerror.bind(this)
});

// 没有路由对象时,就可以结束了
if (!router) {
debug('no routes defined on app');
done();
return;
}

// 调用router的handle方法
router.handle(req, res, done);
};

Router层面


Express里中间件的具体实现在Router对象中。Router包含了Express中最为核心的概念。app中的许多API都是对Router API的简单封装。可以通过app._router来访问app的Router对象。Router的源码位于lib/router/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var proto = module.exports = function(options) {
var opts = options || {};

//Router本身也是一个函数
function router(req, res, next) {
router.handle(req, res, next);
}

// 将下文中所写的方法指定为router的prototype
setPrototypeOf(router, proto)

// Router的属性初始化
...
// 存储中间件的stack属性
router.stack = [];

return router;
};

Router对象有一个关键属性stack,为一个数组,存放着所有的中间件。每一个中间件都是一个Layer对象,如果该中间件是一个路由中间件,则相应的Layer对象的route属性会指向一个Route对象,表示一条路由。

注册

每次调用app.use()时,会执行router.use()stack属性添加一个新的中间件,这个中间件是由Layer对象包装的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
proto.use = function use(fn) {
// 处理输入参数,抽出path和fn
// 过程同app.use
...

if (callbacks.length === 0) {
throw new TypeError('Router.use() requires middleware functions');
}

// 循环fn数组,添加中间件
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];

// 错误检测和打印信息
...

// 创建Layer对象
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);

// 指定route属性为undefined,表示是普通中间件
layer.route = undefined;

// 入栈
this.stack.push(layer);
}

return this;
};

对于路由中间件要复杂些,路由中间件是通过router.route()方法注册的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
proto.route = function route(path) {
// 创建新的Route对象
var route = new Route(path);

// 创建Layer对象
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: this.strict,
end: true
}, route.dispatch.bind(route)); // 绑定this为route对象

// 指定layer的route属性为当前的路由组件,表示是路由中间件
layer.route = route;

// 入栈
this.stack.push(layer);
return route;
};

看来,还需要去route.js中看看这个对象的定义。

1
2
3
4
5
6
7
8
9
10
function Route(path) {
this.path = path;
// 存放路由处理函数的stack
this.stack = [];

debug('new %o', path)

// 方法名和对应handler的键值对
this.methods = {};
}

可以看到,Route对象也有一个stack属性,为一个数组,其中的每一项也是一个Layer对象,是对路由处理函数的包装。我们可以把它理解成一个路由中间件对象。每次调用router.route()的时候,实际上是新建了一个layer放在router.stack中;并设置layer.route为新建的Route对象。

之后,通过route[method].apply(route, slice.call(arguments, 1))为特定方法绑定handler,route[method]定义在route.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
methods.forEach(function(method){
Route.prototype[method] = function(){
var handles = flatten(slice.call(arguments));

for (var i = 0; i < handles.length; i++) {
// handle即用户指定的处理函数数组
var handle = handles[i];

// 检测handle合法性
...
// 新建layer对象
var layer = Layer('/', {}, handle);
layer.method = method;

// 更新this.methods数组,并将layer入栈
this.methods[method] = true;
this.stack.push(layer);
}

return this;
};
});

即,当调用route.VERB()的时候,新建一个layer放在route.stack中。

通过上面的分析可以发现,Router其实是一个二维的结构。一个可能的router.stack结构如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
----------------
| layer1 |
----------------

---------------- layer2.route.stack ------------ ------------ ------------
| layer2 | ------------------> | layer2-1 |-->| layer2-2 |-->| layer2-3 |
---------------- ------------ ------------ ------------

---------------- layer3.route.stack ------------ ------------
| layer3 | ------------------> | layer3-1 |-->| layer3-2 |
---------------- ------------ ------------

----------------
| ...... |
----------------

----------------
| layerN |
----------------

Layer

Layer对象的构造函数如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Layer(path, options, fn) {
// 实现函数名前有无new返回相同的效果
if (!(this instanceof Layer)) {
return new Layer(path, options, fn);
}

debug('new %o', path)
var opts = options || {};

this.handle = fn;
this.name = fn.name || '<anonymous>';
this.params = undefined;
this.path = undefined;
this.regexp = pathRegexp(path, this.keys = [], opts);

// 设置特殊情况的快速匹配
this.regexp.fast_star = path === '*'
this.regexp.fast_slash = path === '/' && opts.end === false
}

Layer的属性和一些方法介绍如下

  • handle 用户指定的中间件函数
  • name 函数名
  • params 参数名,在执行match时赋值
  • path 路径名,在执行match时赋值
  • regexp 路径的正则表达形式,由pathRegexp转换完成
  • keys 路径匹配结果信息
  • route 路由中间件的Route对象,或undefined

上面看到,普通中间件和路由中间件都通过Layer的形式插入在stack中。尽管它们都有构造函数中声明的哪些属性,这两种Layer还是有所区别:

  • Router中的Layer对象具有route属性,如果该属性不为undefined,则表明为一个路由中间件;而Route中的Layer对象没有route属性
  • Route中的Layer对象具有method属性,表明该路由函数的HTTP方法;而Router中的Layer对象没有method属性
  • Route中的Layer对象的keys属性值均为[]regexp属性值均为/^\/?$/i,因为在Route模块中创建Layer对象时使用的是Layer('/', {}, fn)

请求处理

在中间件注册完成后,剩下的工作都是由app.handle()或者说router.handle()完成的。这部分代码比较复杂。大致结构如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
proto.handle = function handle(req, res, out) {

...

// middleware and routes
var stack = self.stack;

...

next();

function next(err) {
...

while (match !== true && idx < stack.length) {
...
}

// no match
if (match !== true) {
return done(layerError);
}

...
}

function trim_prefix(layer, layerError, layerPath, path) {
...
}
};

在初始化和预处理后,调用了next()函数。在next()函数中,主要的部分是while语句判断是否遍历完成整个stack,完成后执行done()。这部分代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
while (match !== true && idx < stack.length) {
layer = stack[idx++];
match = matchLayer(layer, path);
route = layer.route;

if (typeof match !== 'boolean') {
// hold on to layerError
layerError = layerError || match;
}

if (match !== true) {
continue;
}

if (!route) {
// process non-route handlers normally
continue;
}

if (layerError) {
// routes do not match with a pending error
match = false;
continue;
}

var method = req.method;
var has_method = route._handles_method(method);

// build up automatic options response
if (!has_method && method === 'OPTIONS') {
appendMethods(options, route._options());
}

// don't even bother matching route
if (!has_method && method !== 'HEAD') {
match = false;
continue;
}
}

其中layer表示当前中间件,调用matchLayer方法即layer.match(path)判断是否和当前路径匹配(这个过程会更新layer中的pathparams)。之后的逻辑如下:

  1. 如果match不为true,即中间件和路径不匹配,则处理下一个中间件
  2. 如果matchtrueroute不存在,表示不是一个路由中间件,执行continue,之后跳出循环
  3. 如果matchtrue,且route存在。说明是需要的中间件。
    3.1 如果has_methodfalse且HTTP方法为OPTIONS,则执行appendMethods(options, route._options())添加支持方法
    3.2 如果has_methodfalse且HTTP方法不为HEAD,则设置match为false,即该路由无法处理该请求,此时由于match依然满足循环条件,因此会对下一个中间件进行判断
    3.3 如果has_methodtrue,则由于match不再满足循环条件,因此会跳出循环

整体来看,循环的主要作用就是从当前下标开始找出第一个能够处理该HTTP请求的中间件。如果是非路由中间件,则只要匹配路径即可;如果是路由中间件,则需要同时匹配路径和HTTP请求方法。

while语句后,如果matchtrue,说明遍历完成,直接执行done()。否则将匹配中得到的pathparams交给process_params方法作参数预处理。

1
2
3
4
5
6
7
8
9
10
11
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}

if (route) {
return layer.handle_request(req, res, next);
}

trim_prefix(layer, layerError, layerPath, path);
});

从回调函数中可以看到,如果是路由中间件可以直接调用layer.handle_request(req, res, next)执行真正的中间件函数。如果是普通中间件,还需要在trim_prefix中对路径处理后才会调用layer.handle_request(req, res, next)

1
2
3
4
5
6
7
8
9
function trim_prefix(layer, layerError, layerPath, path) {
...

if (layerError) {
layer.handle_error(layerError, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}

在路由中间件中,layer.handle_request(req, res, next)调用的中间函数实际上是route.dispatch方法,在Route对象内容分发请求,它的逻辑相对router.handle要简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
Route.prototype.dispatch = function dispatch(req, res, done) {
var idx = 0;
var stack = this.stack;
if (stack.length === 0) {
return done();
}

var method = req.method.toLowerCase();
if (method === 'head' && !this.methods['head']) {
method = 'get';
}

req.route = this;

next();

function next(err) {
// signal to exit route
if (err && err === 'route') {
return done();
}

// signal to exit router
if (err && err === 'router') {
return done(err)
}

var layer = stack[idx++];
if (!layer) {
return done(err);
}

if (layer.method && layer.method !== method) {
return next(err);
}

if (err) {
layer.handle_error(err, req, res, next);
} else {
layer.handle_request(req, res, next);
}
}
};

可以看到,next函数逻辑像下面这样

  1. 如果有错,直接调用done()传递错误
  2. 获取layer,如果不存在,调用done()
  3. layer和当前方法不匹配时,跳过当前layer,执行next(),继续下一个route函数
  4. layer和当前方法匹配时,根据有无错误执行layer.handle_error(err, req, res, next)或是layer.handle_request(req, res, next)

综上,一个请求到达时,流程顺序像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

----------------
| layer1 |
----------------

----------------
| layer2 |
----------------

---------------- layer3.route.stack ------------ ------------ ------------
| layer3 | ------------------> | layer3-1 |-->| layer3-2 |-->| layer3-3 | ---
---------------- ------------ ------------ ------------ |
|
---------------------------------------------------------------------------

---------------- layer4.route.stack ------------ ------------
| layer4 | ------------------> | layer4-1 |-->| layer4-2 | ---
---------------- ------------ ------------ |
|
------------------------------------------------------------

----------------
| ...... |
----------------

----------------
| layerN |
----------------

每个中间件的处理过程则像下面这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

No --------------------
------| path match |
| --------------------
| ↓ Yes
| -------------------- Yes --------------------- No
| | has route |-------| http method match |------
| -------------------- --------------------- |
| ↓ No | Yes |
| -------------------- | |
| | process params |<----------------- |
| -------------------- |
| ↓ |
| -------------------- |
| | execute function | |
| -------------------- |
| ↓ |
| -------------------- |
----->| next layer |<---------------------------------
--------------------

在参数处理的过程中,每个参数的处理函数只会执行一次,并将结果保存在缓存中。在处理同一个请求的过程中,如果需要处理某个参数,会首先检查缓存,如果缓存中不存在,才会执行其处理函数。

内置中间件

middleware目录下,放置了两个Express内置中间件,在app.lazyRouter中自动加载。

1
2
3
4
5
6
7
8
9
10
11
app.lazyrouter = function lazyrouter() {
if (!this._router) {
this._router = new Router({
caseSensitive: this.enabled('case sensitive routing'),
strict: this.enabled('strict routing')
});

this._router.use(query(this.get('query parser fn')));
this._router.use(middleware.init(this));
}
};

其中第一个的作用是解析URL query,query(this.get('query parser fn'))用于设置URL query解析器。第二个的作用是将req和res分别暴露给对方,并让它们分别继承自express定义的app.requestapp.response

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
exports.init = function(app){
return function expressInit(req, res, next){
if (app.enabled('x-powered-by')) res.setHeader('X-Powered-By', 'Express');
req.res = res;
res.req = req;
req.next = next;

setPrototypeOf(req, app.request)
setPrototypeOf(res, app.response)

res.locals = res.locals || Object.create(null);

next();
};
};

express.js中,reqres分别继承自了request.jsresponse.js的导出对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function createApplication() {
...

// expose the prototype that will get set on requests
app.request = Object.create(req, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})

// expose the prototype that will get set on responses
app.response = Object.create(res, {
app: { configurable: true, enumerable: true, writable: true, value: app }
})

...
}

其他

关于参数处理和视图渲染,我看得不是很仔细,就不再赘述了。有兴趣的可以自行去参考中的链接学习。

参考

0%