简洁代码的重要性:代码读的频率远高于写

Programs must be written for people to read, and only incidentally for machines to execute.

(程序必须书写得能够让人阅读,只是顺便能够让机器执行。)

———— Abelson & Sussman:《计算机程序的构造与解释》


你或许会问:代码真正“读”的成分有多少呢?难道力量主要不是用在“写”上吗?

读与写花费时间的比例超过10:1。写新代码时,我们一直在读旧代码。

既然比例如此之高,我们就想让读的过程变得轻松,即便那会使得编写过程更难。没可能光写不读,所以使之易读实际也使之易写。

这事概无例外。不读周边代码的话就没法写代码。编写代码的难度,取决于读周边代码的难度。要想干得快,要想早点做完,要想轻松写代码,先让代码易读吧。

———— Robert C. Martin《代码简洁之道》


One of Guido's key insights is that code is read much more often than it is written. A style guide is about consistency. Consistency with this style guide is important. Consistency within a project is more important. Consistency within one module or function is the most important.

(代码的读取频率远高于编写频率。 风格指南是关于一致性的。 与此风格指南的一致很重要。 项目的一致性更为重要。 一个模块或功能内的一致性是最重要的。)

———— PEP 8:Python 官方代码风格指南

什么是简洁的代码

简洁的代码都有共同的特点,简洁的代码最好是能够自解释的,当你读这些代码的时候就像读一篇文章一样。关于一些原则,比较推荐 Python 之禅 的描述:

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Python之禅 by Tim Peters

优美胜于丑陋(Python 以编写优美的代码为目标)
明了胜于晦涩(优美的代码应当是明了的,命名规范,风格相似)
简洁胜于复杂(优美的代码应当是简洁的,不要有复杂的内部实现)
复杂胜于凌乱(如果复杂不可避免,那代码间也不能有难懂的关系,要保持接口简洁)
扁平胜于嵌套(优美的代码应当是扁平的,不能有太多的嵌套)
间隔胜于紧凑(优美的代码有适当的间隔,不要奢望一行代码解决问题)
可读性很重要(优美的代码是可读的)
即便假借特例的实用性之名,也不可违背这些规则(这些规则至高无上)
不要包容所有错误,除非你确定需要这样做(精准地捕获异常,不写 except:pass 风格的代码)
当存在多种可能,不要尝试去猜测
而是尽量找一种,最好是唯一一种明显的解决方案(如果不确定,就用穷举法)
虽然这并不容易,因为你不是 Python 之父(这里的 Dutch 是指 Guido 
做也许好过不做,但不假思索就动手还不如不做(动手之前要细思量)
如果你无法向人描述你的方案,那肯定不是一个好方案;反之亦然(方案测评标准)
命名空间是一种绝妙的理念,我们应当多加利用(倡导与号召)

如何写简洁的代码

知道什么是简洁的代码不代表能写出简洁的代码。就像可以用数学公式来完全描绘出来骑自行车的过程,但不代表就会骑车了。这一点需要持之以恒的练习。一些通用的方法包括:

  • 好的命名
  • 合适的抽象(数据、过程)
  • 不多不少的注释
  • 统一的格式

一些详细的方法在《代码整洁之道》里都有详细的介绍。

在写代码的时候,不要只是为了能够实现这个需求,而要考虑实现的逻辑合不合理。一种不好的实现是:改了几遍,它最后能工作了,但我明天就不清楚它为啥能工作了

读的话,如果能读各种源码的话最好,另一个很有用的方法就是读自己的代码。每隔一段时间读一下自己之前写的代码,总是能发现可以提高的地方。

一些值得参考的代码规范

需要有强制的代码风格吗?

我们有一个统一推荐的代码风格,但是由于历史原因,我们并没有强制执行。因为这些代码写出来的时候还没有这一规定,我们的首选是兼容它的风格(在修改其他人代码的时候也遵循这一准则);

另外也有意见认为,代码风格是相对虚幻的东西,强制团队内所有人风格一致会产生更多问题。

所以,我们有统一推荐的风格,期望大家能尽量保持统一;也允许有一些个人的风格。但是有一点是确认的:不管是自己新建,还是修改别人的代码,同一个模块、文件、函数内的风格要保持一致。如果自己新建文件,就自己来维护风格一致;如果修改别人的文件,就与原文件保持兼容风格。所以我们也提供了统一的格式化工具,鼓励大家在可能的情况下来使用它来保持整个项目风格统一。

我们采用的一些风格与其原因:

格式化工具 prettier 有其默认风格。除了默认风格外,我们有一些自定义的。

抛开格式化工具,一些值得注意的风格是:

printWidth: 100:单行最大字符数

默认是 80,由于现在显示器越来越大,所以我们扩展到 100,但是我们经常还会看到超过 150,甚至 200 的,这种不换号的话一个显示器都显示不下了。

// VSCode 添加标尺,提示自己的代码已经过长了
"editor.rulers": [
    80,
    100,
],

trailingComma: 'all': 多行模式添加逗号

这个主要是为了减少 git diff。

// before
let items = [
    1,
    2,
    3
]

// after
let items = [
    1,
    2,
    3,
    4
]

上述例子,在向数组添加一个元素后,会引起一行修改(3)和一行添加(4)。

let items = [
    1,
    2,
-   3
+   3,
+   4
]

如果 3 的后边加了逗号,就会只有一行添加。

let items = [
    1,
    2,
    3,
+   4,
]

二元操作符中间加空格

let x=a+b-c+d

vs

let x = a + b - c + d

尽量少用三元操作符

let result = value === '1' ? '一' : value === '2' ? '二' : '三'

// vs

let result
switch (value) {
    case '1':
        result = '一'
        break
    case '2':
        result = '二'
        break
    default:
        result = '三'
        break
}

// 或者
let result = {
    '1': '一',
    '2': '二',
}[value] || '三'

采用 4 个空格缩进

这是一个约定俗成的,但是也可以用 2 个,或者 Tab。唯一要确定的是:不能有混用的情况。

类似的还有很多,可以去 Airbnb 风格推荐那里查看。

一个例子

下边是一个简单的例子,但是暴露的问题在我们的代码中都是真实存在的(第一眼,你能找到这个组件的 render 方法吗?)。

  • import 与 class 之间应该有空行
  • 混乱的缩进,不统一而且混合用了 Tab 和空格
  • <Button> 元素这一行太长
  • 单双引号混用
  • 混乱的命名:同一个函数内 item vs itmind vs idx
  • 类名没有遵循规范(App not app)
  • 各种操作符之间缺少空格,可读性很差
  • 无用的引入:InputModal
  • 错误的使用数组方法(map 返回了一个无用的数组)

我想读这样的代码肯定很折磨,更不用说要在这样的代码基础上做修改,估计很快头就大了。

import React,{Component} from 'react';

import {Button,message,Input,  Modal} from "antd"
class app extends Component{
        items = [{
                items:[1,2,3]},{
                    items:[4,5,6]
                }
        ]
onClick=()=>{
                try{
            message.success('ok')
        }

 catch(error) {
            message.error('not ok')
           }


      finally {
            message.success('finally')
        }
    this.items.map((item, ind)=>{
        item.items.forEach((itm, idx)=>{
            // doSth
        })
    })
    }
      render() {
    return <Button onClick = {this.onClick} type="primary" size="small" shape="circle" htmlType="submit" icon="submit" ghost disabled loading>按钮</Button>
}
}
export default app

但是我们只需要做出一点改变,就能很大的提升代码可读性。上边列出的大部分问题都可以用格式化工具来解决,命名方面的,只需要我们多用点心,就可以提升可辨识度。下边是修改后的代码:

import React, { Component } from 'react'
import { Button, message } from 'antd'

class App extends Component {
    items = [
        {
            items: [1, 2, 3],
        },
        {
            items: [4, 5, 6],
        },
    ]

    onClick = () => {
        try {
            message.success('ok')
        } catch (error) {
            message.error('not ok')
        } finally {
            message.success('finally')
        }
        this.items.forEach((item, index) => {
            item.items.forEach((subItem, subIndex) => {
                // doSth
            })
        })
    }

    render() {
        return (
            <Button
                onClick={this.onClick}
                type="primary"
                size="small"
                shape="circle"
                htmlType="submit"
                icon="submit"
                ghost
                disabled
                loading
            >
                按钮
            </Button>
        )
    }
}
export default App

PS

有无数的指南教我们如何写简洁的代码,但是即便是有经验的程序员也不能写出百分之百简洁优美的代码,Dan Abramov,React 核心开发者,Redux 作者,在前几天发推说

I used to think “clean” means code broken down in small functions, no repetition, no comments. Now I think of it more as code with few possible control flow combinations, direct style (can always trace what connects to what), doesn’t violate grep test, comments explain why.

我曾经认为“整洁”意味着代码被分解为小的函数,没有重复,没有注释。 现在我更倾向于认为是尽可能少地使用控制流组合,风格直接(总是可以追踪什么是什么),不违反grep测试,注释解释原因。

只有不停地练习并思考,才能持续地改进。

如果我们在一个组件内使用多个`useState Hooks, React 如何知道哪个 Hooks 对应哪个 state?

为什么不要在 if 内使用 Hooks

我们以 React-Dom/server 内的 React Hooks 实现来进行探究[1]

React Hooks的结构是一个链表型的数据结构: 每一个 Hooks 对象结构为:

{
    memoizedState: null,
    queue: null,
    next: null,
}

有一个链表的头部,在 React 内部称为 firstWorkInProgressHook,保存着组件内所有的 Hooks。

还有一个链表,workInProgressHook,对应的是上述链表的一个节点,在组件内部就是调用useState() 的时候的当前的 Hooks。每一次我们调用useState(),React 内部会调用一个方法来生成一个 workInProgressHook,其源代码为

function createWorkInProgressHook(): Hook {
  if (workInProgressHook === null) {
    // This is the first hook in the list
    if (firstWorkInProgressHook === null) {
      isReRender = false;
      firstWorkInProgressHook = workInProgressHook = createHook();
    } else {
      // There's already a work-in-progress. Reuse it.
      isReRender = true;
      workInProgressHook = firstWorkInProgressHook;
    }
  } else {
    if (workInProgressHook.next === null) {
      isReRender = false;
      // Append to the end of the list
      workInProgressHook = workInProgressHook.next = createHook();
    } else {
      // There's already a work-in-progress. Reuse it.
      isReRender = true;
      workInProgressHook = workInProgressHook.next;
    }
  }
  return workInProgressHook;
}

我们以组件的两次 render 来介绍该链表是如何初始化以及如何应对更新的:

  • 初始条件下(组件还未渲染),firstWorkInProgressHookworkInProgressHook 都是 null 0️⃣
  • 初次渲染:
    • 第一个 Hooks:firstWorkInProgressHook = workInProgressHook = createHook() 1️⃣
    • 第二个 Hooks:workInProgressHook = workInProgressHook.next = createHook() 2️⃣
    • ...
    • 第 n 个 Hooks: workInProgressHook = workInProgressHook.next = createHook() 3️⃣
    • 渲染结束,React 调用 finishHooks,重置 workInProgressHooknull 4️⃣
// 0️:
// firstWorkInProgressHook = workInProgressHook = null
firstWorkInProgressHook
                        
                          null
                        
     workInProgressHook

// 1:
// workInProgressHook = workInProgressHook.next = createHook()
          firstWorkInProgressHook
                                 
                          null    Hook1
                                 
              workInProgressHook

// 2:
// workInProgressHook = workInProgressHook.next = createHook()
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2
                                           
                         workInProgressHook
// 3:
// workInProgressHook = workInProgressHook.next = createHook()
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                                                          
                                        workInProgressHook
// 4:
// workInProgressHook = null
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                          
        workInProgressHook

下一次 render:

  • 开始前,链表状态和上次 render 结束一致 0️⃣
  • 第一个 Hooks:workInProgressHook = firstWorkInProgressHook1️⃣
  • 第二个 Hooks: workInProgressHook = workInProgressHook.next2️⃣
  • ...
  • 第 n 个 Hooks: workInProgressHook = workInProgressHook.next3️⃣
  • 渲染结束,React 调用 finishHooks,重置 workInProgressHooknull 4️⃣
// 0:
// firstWorkInProgressHook = Hook1
// workInProgressHook = null
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                          
        workInProgressHook

// 1:
// workInProgressHook = firstWorkInProgressHook(Hook1)
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                                 
               workInProgressHook

// 2:
// workInProgressHook = workInProgressHook.next(Hook2)
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                                           
                         workInProgressHook
// 3:
// workInProgressHook = workInProgressHook.next(HookN)
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                                                          
                                        workInProgressHook
// 4:
// workInProgressHook = null
          firstWorkInProgressHook
                                 
                           null   Hook1 ⟶️ Hook2 ...⟶️... HookN
                          
        workInProgressHook

所以:

  • 初次渲染的时候,Hooks 链表为空,每次 useState() 的时候都会新建一个 Hooks 作为当前的 Hooks(workInProgressHook)
  • 再次渲染的时候,按照调用顺序,依次取上次生成的 Hooks 链表各个节点(每个节点就是一个 Hooks)

这也是为什么我们需要保证在每次渲染的时候各个 Hooks 以相同的顺序被调用,也是为什么不要在 if 内使用 Hooks 的原因:这会导致 Hooks 调用顺序不同。

ps

React Hooks 有多个实现。实际上它的代码并不是在 React 内。

如果我们查看 ReactHooks.js 导出的 useState,它的代码为

export function useState<S>(initialState: (() => S) | S) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

resolveDispatcher 的代码为:

function resolveDispatcher() {
  const dispatcher = ReactCurrentDispatcher.current;
  return dispatcher;
}

我们再看 ReactCurrentDispatcher:

const ReactCurrentDispatcher = {
  current: (null: null | Dispatcher),
}

ReactCurrentDispatcher 只是一个普通 JavaScript 对象,甚至它的 current 属性是 null

这是一个典型的依赖注入,React 本身并没有实现各个 Hooks,而是交由不同的模块来进行不同的实现。就像 setState 一样,React 组件可以调用 setState,React-Native 组件也可以调用这个方法,React 会自己决定对 Dom 还是 Native 的更新。

同样的,Hooks 在开发环境、生产环境、server 环境、React-Native 内,甚至第一次渲染、更新渲染时都有不同的实现,React 所做的,就是在不同的环境下使用不同的 ReactCurrentDispatcher来调用正确的 Hooks 实现。

本文分析采用了 React-Dom/server 的实现,虽然它和我们生产中通常调用的版本不同,但是其内部原理是统一的。

cherry-pick 是一个比较常用的 git 操作,可以将一个分支上的 commit “精选”到另一个分支上。然而在最近的开发过程中,却时不时的遇到 merge 冲突。在下文中,我将会详细的分析 cherry-pick 造成冲突的原因,以及 cherry-pick 可能造成的其他更严重问题。

我们以一个简单的例子来进行分析:

the-cp

如上图:我们有一个 master 分支,以及一个 feature 分支。这个例子中我们只关注其中的一行代码,其初始值(commit A)为 apple。在此基础上我们进行了如下操作:

  • 从 master 分支 checkout 出来了 feature 分支
  • 在 feature 分支进行了 F1 提交,在 master 分支进行了 M1 提交,这些提交都与 apple 这行代码无关
  • 在 feature 分支进行了 F2 提交,将 apple 修改为 berry
  • 我们将 F2 提交 cherry-pick 到 master 分支(以虚线表示一次 cherry-pick)

现在 feature 和 master 分支上这一行代码都是 berry

当最后我们将 feature 分支正式往 master 合并的时候,可能出现三种情况:

  1. 这一行代码在两个分支上都没有再修改过,顺利合并
  2. 这一行代码之后在两个分支上又有了修改,结果出现了冲突(⚠️解决冲突很麻烦)
  3. 这一行代码之后在两个分支上又有了修改,没有冲突,顺利合并(❌但是可能导致更严重的错误)

1. 这一行代码在两个分支上都没有在修改过,顺利合并

这是最理想的情况,不做分析。

2. 这一行代码之后在两个分支上又有了修改,结果出现了冲突

the-cp

如上图所示,在 cherry-pick 之后,我们又进行了如下操作:

  • 在 master 分支进行了 M3 提交,该提交并未修改 berry 这行代码
  • 在 feature 分支进行了 F3 提交,将 berry 修改为了 cherry
  • 尝试将 feature 分支往 master 合并:⚠️冲突发生

分析:合并时,M3 和 F3 的最近公共祖先(merge base)是 commit A,这行代码为 apple,然后对比发现,master 分支上这行代码由 apple 变成了 berry,feature 分支上这行代码由 apple 变成了 cherry,所以冲突出现了。

如果我们使用的是 merge 呢?那 M2 到 F2 将是一条实线,M3 和 F3的最近公共祖先(merge base)会是 F2,master 上:berry -> berry,feature 上 berry -> cherry,因为 master 分支相对 F2没有修改,所以就没有冲突。

这只是两个分支的简单情况,随着分支增多,冲突会越来越难以处理。

而且这是有冲突的情况,还有一种情况是没有冲突,却可能引发更严重的问题。

3. 这一行代码之后在两个分支上又有了修改,没有冲突,顺利合并

为了更直观,我们将这一行代码视为一个功能的开关配置:apple 代表这个功能是上线状态,berry 代表这个功能是下线状态。

the-cp

如上图,最开始(A),我们这一行代码是 apple,代表功能为上线状态。

然后我们发现了一些 bug,需要将该功能紧急下线,我们:

  • 在 feature 分支上下线该功能(F2):apple -> berry
  • 然后将该操作 cherry-pick 到 master(M2),现在 master 上该功能也下线了
  • 然后我们在 feature 分支上进行了 bug 修复,最终解决了 bug,我们在 feature 分支上将该功能上线(F3):berry -> apple
  • 然后我们决定将 bug 修复 merge 到 master
  • merge 顺利完成,没有冲突。但是:这行代码仍然是berry,下线状态

merge 分析:M3(berry) 和 F3(apple) 的最近公共祖先是 A(apple),因此 git 认为 feature 分支并未修改 apple 的值,结果合并后 master 分支上这行代码还是 berry,我们的功能在 master 上还是下线状态。

结论

在开发中,不要使用 cherry-pick 来进行不同分支之间代码的同步,这很可能会造成最终合并时出现冲突,而且可能产生比冲突更严重的问题:该有冲突却没有冲突。

ps: 何时使用 cherry-pick

当我们需要在不同分支之间移动提交时,可以使用 cherry-pick。

本文主要参考了 Raymond Chen 的系列文章Stop cherry-picking, start merging。作者作为 Windows 开发团队的一员,因为项目需要有很多分支相互 merge,所以 cherry-pick 很容易造成问题。但是如果你的两个分支是两个单独的分支,永远不会相互 merge,那么就可以使用 cherry-pick 而不用担心上述问题。

参考:

跨域(Cross-Origin)或者说跨域资源共享(Cross-Origin Resource Sharing)是一个很常见的问题,现在 Web 各个方面都会涉及,正因为如此,它涉及到的内容多而且杂,对于一些特定的使用场景,经常在遇到的时候查一下资料解决了,过几天又忘记了。因此本文就将跨域涉及的问题,做一个(力求)全面的总结。

注:以下文字部分来源于 MDN

目录

什么叫跨域?

跨域资源共享(CORS) 是一种机制,它使用额外的 HTTP 头来告诉浏览器:让运行在一个 origin (domain) 上的Web应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时(两者不符合同源策略),资源会发起一个跨域 HTTP 请求(CORS request)。

比如,站点 http://domain-a.com 的某 JavaScript 脚本通过 Fetch API 请求 http://api.domain-b.com/data.json。

什么是同源策略?

同源策略用于判断一个请求是否跨域,如果两个 URL 的

  • 协议
  • 域名
  • 端口

都相同的话,那这两个 URL 就是同源的,否则为非同源。

同源策略限制了哪些内容?

同源策略限制了两个不同源的站点资源如何共享,关于是否允许,根据 MDN 可以总结为下边三类:

  • 通常允许跨域写操作(Cross-origin writes)。例如链接(links),重定向以及表单提交。特定少数的HTTP请求需要添加 preflight。
  • 通常允许跨域资源嵌入(Cross-origin embedding)。之后下面会举例说明
  • 通常不允许跨域读操作(Cross-origin reads)。但常可以通过内嵌资源来巧妙的进行读取访问。例如可以读取嵌入图片的高度和宽度,调用内嵌脚本的方法

以下是可能嵌入跨源的资源的一些示例:

  • <script src="..."></script> 标签嵌入跨域脚本。语法错误信息只能在同源脚本中捕捉到。
  • <link rel="stylesheet" href="..."> 标签嵌入CSS。由于CSS的松散的语法规则,CSS的跨域需要一个设置正确的Content-Type 消息头。不同浏览器有不同的限制: IE, Firefox, Chrome, Safari (跳至CVE-2010-0051)部分 和 Opera。
  • <img>嵌入图片。支持的图片格式包括PNG,JPEG,GIF,BMP,SVG,...
  • <video><audio>嵌入多媒体资源。
  • <object>, <embed><applet> 的插件。
  • @font-face 引入的字体。一些浏览器允许跨域字体( cross-origin fonts),一些需要同源字体(same-origin fonts)。
  • <frame><iframe> 载入的任何资源。站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互。

什么是简单请求、预检请求?

CORS 规范规定了,对于可能对服务器数据产生副作用的 HTTP 请求,需要首先发送一个预检请求(preflight request)。而不需要预检请求的请求,就是简单请求。详细定义如下:

简单请求: 如果一个请求满足以下所有条件,就是一个简单请求

  • 使用下列方法之一:
    • GET
    • HEAD
    • POST
  • Fetch 规范定义了对 CORS 安全的首部字段集合,不得人为设置该集合之外的其他首部字段。该集合为:
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (需要注意额外的限制)
    • DPR
    • Downlink
    • Save-Data
    • Viewport-Width
    • Width
  • Content-Type 的值仅限于下列三者之一:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
  • 请求中的任意XMLHttpRequestUpload 对象均没有注册任何事件监听器;XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问。
  • 请求中没有使用 ReadableStream 对象。

*简单请求不必发送预检请求,但是仍然是一个跨域请求,如果服务器没有返回合适的 Header,那该请求仍有可能被禁止。

如果上述任一条件不满足,则该请求不是简单请求,需要发送预检请求。

以下是一个需要预检请求的跨域请求,以及该预检请求的整个发送过程:

fetch('http://demo.io/query',{
    method:'POST',
    headers:{
        "Content-Type":'application/json',
    },
    body:JSON.stringify(query)
})

因为包含了额外的请求头,则需要首先发送一个OPTIONS方法的预检请求。

OPTIONS /query HTTP/1.1
HOST demo.io
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type

第三行的请求头告诉服务器,该请求会是POST方法,第四行的请求头告诉服务器,该请求会有一个自定义Content-Type请求头。

下边是对该预检请求可能的服务器返回:

Access-Control-Allow-Origin: * # 允许发送该请求的域名
Access-Control-Allow-Methods: POST, GET, OPTIONS # 允许的请求方法
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type # 允许的请求头
Access-Control-Max-Age: 86400 # 该响应的有效期,有效期内无需对该请求再次预检

由于我们的POST方法与Content-Type请求头都在允许范围内,因此客户端就可以发送实际的请求了。

跨域请求如何携带身份凭证(Cookie)?

正常情况下,跨域请求不会携带身份凭证,比如Cookie;但是通过客户端与服务端的一些配置,可以实现跨域请求携带认证信息。以一个在 http://foo.com 上的一个 Fetch 请求为例:

1. 配置 fetch 请求允许携带认证信息,

// credentials: 'include'将会允许该请求携带 cookie 信息
fetch('http://bar.com', {
    credentials: 'include',
})

2. 服务器返回头需要允许携带认证信息

# 如果返回 Header 没有该项,浏览器将不会把数据返回给请求者
Access-Control-Allow-Credentials: true

3. 允许的请求域必须为请求者的域

# 此处不得使用通配符 *,否则同样会请求失败
Access-Control-Allow-Origin: http://foo.com

跨域请求携带的 Cookie 将被视为第三方 Cookie,如果浏览器使用者设置了不允许第三方 Cookie,则该认证信息将不会被携带。

用于 CORS 的 HTTP 首部有哪些?具体作用是什么?

  • 请求头部
    • Origin - 预检请求或实际请求的源站
    • Access-Control-Request-Method - 用于预检请求。其作用是,将实际请求所使用的 HTTP 方法告诉服务器。
    • Access-Control-Request-Headers - 用于预检请求。其作用是,将实际请求所携带的首部字段告诉服务器。
  • 返回头部
    • Access-Control-Allow-Origin - 指定了允许访问该资源的外域 URI
    • Access-Control-Expose-Headers - 指定了浏览器访问的首部白名单
    • Access-Control-Max-Age - 指定了针对该请求的预检请求的有效期
    • Access-Control-Allow-Credentials - 指定了当浏览器的credentials设置为true时是否允许浏览器读取response的内容。当用在对preflight预检测请求的响应中时,它指定了实际的请求是否可以使用credentials。
    • Access-Control-Allow-Methods - 用于预检请求的响应。其指明了实际请求所允许使用的 HTTP 方法
    • Access-Control-Allow-Headers - 用于预检请求的响应。其指明了实际请求中允许携带的首部字段

Fetch API 的 mode 选项是做什么的?

Request.mode用于确定跨域请求能否有一个合法的返回,以及返回的那些属性可以被读取。该选项有以下几个值:

  • same-origin - 不能发送跨域请求,否则会抛出错误
  • no-cors - 指定该请求只能是简单请求指定的三种方法GET/HEAD/POST)之一、只能包含简单请求指定的简单首部。如果一个 mode 为 no-cors 的请求成功的话,将会返回一个opaque filtered response,该 Response 的URL list为空数组、status为 0,status message 为空字节序列,header list 为空,body为 null,也不能获取json属性。通常,前文描述的嵌入式内容默认请求模式即为此。可以设置其crossOrigin属性改变请求模式。
  • cors - 允许跨域请求,可以携带额外的请求首部
  • navigate 主要用于文档间的导航

image.crossOrigin = 'anonymous'是什么意思?

通常嵌入式的内容,比如<img><script>请求不会触发跨域,但有时候我们需要一个跨域请求,比如防止下边描述的画布污染,这时候就需要设置crossOrigin属性,该属性有两个值:

  • anonymous 匿名请求,不携带认证信息(Cookie)。Request.mode 将是 cors,而 credentials mode将是same-origin
  • use-credentials 携带认证信息,如果不符合上文描述的携带认证要求,会抛出错误。Request.mode 将是 cors,而 credentials mode将是include

为什么<img>会污染canvas画布?

如果不指定<img>标签的corssOrigin属性,此内容不会触发跨域,因此返回数据视为从另一个域取得的。如果将其数据绘制到canvas上,该画布就被污染了,我们将不能使用其数据。下边的方法都会抛出错误:

  • context.getImageData()
  • canvas.toBlob()
  • canvas.toDataURL()

如果我们要想使用该图像数据,必须:

1. 发送一个跨域请求

我们可以设置<img>crossOrigin属性:

<img src="http://another.com/demo.png" crossOrigin="anonymous" />

或者指定我们的Image对象的crossOrigin属性:

let image = new Image()
image.crossOrigin = 'Anonymous'
image.src = imageUrl

2. 服务器配置允许该跨域请求:

Access-Control-Allow-Origin: *

这样,我们就可以使用该图像数据了。

什么是跨站请求伪造(CSRF)?

CSRF(Cross-Site Request Forgery) 攻击主要不在于跨站(CS),而在于请求伪造(RF)。

介绍 CSRF 之前,我们先看下我们比较熟悉的 JSONP:在 CORS 被广泛支持之前,JSONP 曾被广泛应用作为跨域资源使用的方法,现在很多网上文章提到如何解决跨域,还是首先提到 JSONP。JSONP 的原理就是利用<script>标签不受同源策略限制,我们可以将其 src 属性设置为一个不是真正 JavaScript 脚本的资源,然后加载并执行返回结果。如上文所说,此类嵌入式内容如果不设置corssOrigin属性,将不会触发跨域,也可以正常携带认证信息如 Cookie。而在 CORS 被广泛支持后,就应该使用 CORS 来更好地解决跨域资源共享问题。

与 JSONP 类似,CSRF 攻击者的一种攻击方法也是利用 <script><img> 标签来进行攻击。例如,攻击者将如下一段代码放到他搭建的个人网站上:

<!-- 向黑客转账 -->
<script src="http://youbank.com/account?action=transfer&target=hacker&ammount=10000"></script>
<!-- 或者 -->
<img src="http://youbank.com/account?action=transfer&target=hacker&ammount=10000" />

然后攻击者诱导用户访问该网站,通常来说该访问不会有什么问题,但如果访问者之前不久刚访问过其银行账户网站,他的认证信息仍然有效并且保存在浏览器上,那么在访问者访问该网站并加载上述代码后,就会发送一个携带了自己认证信息的请求,银行网站以为是用户的授权请求,因此会执行成功。

在 CSRF 攻击过程中,黑客并不知道用户的认证信息,也不能获取 opaque filtered response 的返回内容,他所做的就是伪造一个用户的请求。因此应该主要防范会对数据产生副作用的请求行为,方法主要有:

  • 验证 HTTP Referer 字段
  • 在请求地址中添加 token 并验证
  • 在 HTTP 头中自定义属性并验证

如果想更深入了解 CSRF,可参阅下边文章:

什么是CORB?

问题

上文提到过,对于嵌入式内容,浏览器通常是允许其请求的。<img><script>标签不受同源策略影响。因为我们(通常)不能利用比如上边提到的CSRF攻击方式来读取跨域数据,因此看起来对他们的请求是安全的,但是其实不然。比如一个<img>标签:

<img src="http://yousite.com/secret.json" />

浏览器期望有一个 image 类型的返回,但是返回数据是json格式的,在将数据加载到内存后,render process 决定不使用该数据来渲染图像,但是数据还是加载到了内存内,攻击者可能会想办法从 render process 内读取该敏感数据(已知的攻击方式如幽灵漏洞)。

另一个已知的攻击方式是利用<script>标签:

<script>
// 攻击者重载了 Array 数组构造函数
function Array(){
    var that = this;
    var index = 0;
    // 定义数组 setter
    var valueExtractor = function(value) {
        // 窃取数据
        steal(value);
        // 对下一个 index 的元素设置 setter
        that.__defineSetter__(index.toString(),valueExtractor );
        index++;
    };
    // 对数组 index:0 的元素设置 setter
    that.__defineSetter__(index.toString(),valueExtractor );
    index++;
}
</script>
<!-- 
该请求可能返回敏感 JSON 数据,比如
[{
    "secret": "value"
}] 
-->
<script src="http://yousite.com/secret.json"></script>

由于script>标签在加载后会执行返回数据,而攻击者通过其脚本重写了数组构造函数Array,在设置数组属性时来偷取数据;在加载执行返回的 JSON 数据时,构造数组会运行攻击者重写的版本,从而偷取用户数据。所以现代浏览器已经对此进行了修复:

// 现代浏览器不会运行上述代码中的 setter
let array = new Array(1, 2, 3)

这也是为什么 Gmail、Facebook 返回的 JSON 数据会以一个无限循环开头

for(;;);
[{
    "secret": "value"
}]

这样如果攻击者企图运行返回的数据,会卡在无限循环处,不会运行循环后的真实数据。

解决方案

一旦数据被加载到浏览器线程内,就有被窃取的可能性,因此我们可以:

  1. 采用类似 CSRF 的防范方法,直接不返回该敏感数据
  2. 让返回数据不被加载到渲染线程内存空间内

第二种方法就是 Google 推行的 CORB 标准,现在已经是Fecth规范的一部分。简单来说,CORB 是一种可以识别可疑跨域资源请求的算法,并且在请求返回到达网页之前阻止该请求,从而防止敏感数据泄露。

该算法主要依赖于 HTTP 返回头部信息,当符合 CORB 规则时,浏览器就会不会加载该返回。关于该算法的具体规范与相关介绍,可参阅:

参考

最近写了一个 React 水印组件,为了支持水印防隐藏,采用了MutationObserver,主要思路如下:

  • 一个 MutationObserver 用于监测水印父节点(parentNode)的childList,在水印被手动删除之后,重新插入新的水印
  • 一个 MutationObserver 用于监测水印自身属性(attributes),在水印属性(displayopacityvisibility等)变化后重置属性值,防止通过修改 style 隐藏水印

我们只讨论如何监测自身属性来防止属性修改,主要有 2 个思路:

1. 设定一个初始值,任何属性变化时都将 style 属性重置为初始值

const callback = (mutationsList, observer) => {
    for (var mutation of mutationsList) {
        observer.disconnect()
        mutation.target.setAttribute('style', initStyle)
        observer.observe(node, options)
    }
}

演示 1

See the Pen React WaterMark Component by LiYan (@liyan) on CodePen.

2. 监测 oldValue,任何属性修改都将属性值重置为 oldValue

const callback = (mutationsList, observer) => {
    for (var mutation of mutationsList) {
        observer.disconnect()
        let { oldValue, attributeName } = mutation
        mutation.target.setAttribute(attributeName, oldValue)
        observer.observe(node, options)
    }
}

演示 2

See the Pen WaterMark use oldValue by LiYan (@liyan) on CodePen.

方法 2 与方法 1 相比,可以重置任何属性的改变,而方法 1 只能重置我们指定的几个属性。看起来方法 2 是更好的选项,事实如此吗?

注意演示 2 的 hideshow 按钮,点击发现,可以改变水印的  style 属性实现水印隐藏,其中 hide 按钮的事件处理函数为:

onClick = () => {
    let watermark = document.getElementById('watermark')
    watermark.style.display = 'none'
    watermark.style.display = 'block'
}

为什么这样会实现水印的隐藏呢?其实在于 JavaScript 的事件运行机制(任务队列)。

关于任务队列,此处不做详述,可以参看这篇文章Tasks, microtasks, queues and schedules。主要就是:

  • JavsScript 基于事件循环,每个循环会先处理当前循环的宏任务,然后会处理当前循环的微任务
  • 微任务处理完后会进入下一个事件循环
  • 如此循环往复

比如 setTimeout 就会产生一个宏任务,在当前任务运行完成之后再运行,而一个 fullfilled 的 Promise 、我们讨论的 MutationObserver 则是微任务的代表,基于此,我们来解释为什么上述代码会实现水印的隐藏。

当我们点击 hide 按钮时,就会产生一个任务,运行 onClick 回调函数

运行第一次属性修改

watermark.style.display = 'none'

style.displayblock 改变为none,这里会在微任务队列内推入一个微任务(MutationObserver callback, 其 mutationList 有一个 MutationRecord,其 oldValue 为 display: block)。

click 任务继续运行

watermark.style.display = 'block'

 style.displaynone 改变为block,这里和 Promise 不同的是,因为该 MutationObserver 已经有一个 callback 在微任务队列内了,不会再次推入一个新的微任务,而只是将一个新的 MutationRecord 推入 mutationList,该 MutationRecord 的 oldValue 为 display: none)。

click 任务运行完成,开始运行微任务

  • 微任务 mutation callback 开始运行, 循环 mutationList:
    • 第一个 MutationRecord 使 style 属性恢复到 oldValuedisplay 重置为 block
    • 第二个 MutationRecord 使 style 属性恢复到 oldValuedisplay 重置为 none

由此就实现了水印的隐藏。

具体过程如下:

style 初始值 display: block

click:

  • 任务开始
    • watermark.style.display = 'none'
      • styledisplay: block -> display: none,MutationObserver 入队微任务 callback, mutationList 有一个 MutationRecord,其 oldValue 为 display: block
    • watermark.style.display = 'block'
      • styledisplay: none -> display: block,MutationObserver callback 已存在,会推入一个新的 MutationRecord 到 mutationList,其 oldValue 为 display: none
  • 任务运行结束,开始微任务队列,执行 callback
    • mutationRecord 1:恢复 oldValue:display: block
    • mutationRecord 2:恢复 oldValue:display: none

所以我们的方法 1 虽然只能锁定几个指定的属性,但是在任何情况下属性都会重置为初始值,因此可以实现对属性的真正锁定。

参考