Giter VIP home page Giter VIP logo

blogs's Introduction

blogs's People

Contributors

luokuning avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

Forkers

witchycq lcyh

blogs's Issues

如何同时使用多个不同版本的 Node.js 及 npm 来编译项目

这篇文章并不是关于使用 nvm 或者 n 等版本工具的指南,而是探讨如何在系统中同时安装两个或多个不同版本的 Node.js。注意是 同时 存在,比如在终端输入 node1node2 时,可以分别执行两个不同版本的 Node.js,而不需要版本管理工具事先进行切换。

缘由

同时在系统中安装多个不同版本的 Node.js 跟 npm 听起来像是个伪需求,毕竟当我们需要某个特定版本的 Node.js 时,使用版本工具进行安装、切换都很方便。

不过前些天公司一个运维的小伙伴刚好跟我讨论这个问题,他想在机器上已经安装了一个 Node.js 的情况下,再安装一个不同版本的 Node.js (通过 node2, npm2 来调用),然后执行 npm2 run build 的时候用 node2 来编译项目,但是经过他实际测试发现 build 会失败,不清楚问题出在哪里,出于好奇的目的我打算自己试一试。

实验

为了不搞乱自己的电脑,我用 docker 启动了一个 ubuntu,在这个干净的 ubuntu 里实验。

从官网下载 v10.16.0v8.12.0 两个版本的 Node.js,解压后分别进入各自的目录执行:

./configure
make

进行编译。编译完成后分别将各自的 node, npm 可执行文件链接到 /usr/local/bin 目录下:

# v10.16.0
ln -s `pwd`/out/Release/node /usr/local/bin/node
ln -s `pwd`/deps/npm/bin/npm-cli.js /usr/local/bin/npm

# v8.12.0
ln -s `pwd`/out/Release/node /usr/local/bin/node1
ln -s `pwd`/deps/npm/bin/npm-cli.js /usr/local/bin/npm1

最后得到的结构如下所示:

/usr/local/bin/
|-- node -> /root/node-v10.16.0/out/Release/node
|-- node1 -> /root/node-v8.12.0/out/Release/node
|-- npm -> /root/node-v10.16.0/deps/npm/bin/npm-cli.js
`-- npm1 -> /root/node-v8.12.0/deps/npm/bin/npm-cli.js

我们在终端里试一下命令:

image

后来发现这张图里有个 typo,最后的 npm 本来应该为 npm1,输出的版本应该是 6.4.1,如果我没记错的话。暂时没纠正这个错图了,因为 docker container 被我删了...

可以看到目前系统里存在两个不同的命令 nodenode1,分别指向 v10.16.0v8.12.0,以及各自搭配的 npm。

编译

安装成功后,现在是时候体验一下不同版本的 Node.js, npm 实际使用效果了 。为了简化操作,我们选用 preact 项目来实验 npm run build

在正式运行 npm 命令之前,我们先查看一下 npm1 这个命令本身的代码,执行 vim /usr/local/bin/npm1 可以看到第一行 (shebang) 是:

#! /usr/bin/env node

这一行的意思是,找到当前系统中叫 node 的命令来执行此文件。回想一下我们之前的链接操作,node 是指向 v10.16.0 版本的,也就是说我们直接执行 npm1 -v 的时候,其实调用的是 v10.16.0 版本的 node 来运行 npm1 。为了匹配正确的 node 版本,这里我们需要把 npm1 的 shebang 改成:

#! /usr/local/bin/node1

改完保存并且克隆 preact 的项目后,我们执行 npm1 install,这一步显示成功。

继续执行 npm1 run build,编译成功。不过有个 warning 引起了我的注意:

npm WARN lifecycle The node binary used for scripts is /usr/local/bin/node but npm is using /root/node-v8.12.0/out/Release/node itself. Use the `--scripts-prepend-node-path` option to include the path for the node binary npm was executed with.

这里的意思是,如果我们想让运行 run-script 命令时,用的 node 版本与运行当前 npm 的版本一致的话,应该要传递一个 --scripts-prepend-node-path 参数。事实上,通过查看调试 npm 自身的代码可以发现,这个参数会使当前执行 npm 的 node 的可执行文件所在的目录,优先前置到 run-script 开启的子进程的 PATH 环境变量中,这样在实际执行 build 命令时,用的版本跟执行 npm1 的版本保持一致。可以看下 /usr/local/lib/node_modules/npm/node_modules/npm-lifecycle/index.js 中第 118 行的代码:

  if (shouldPrependCurrentNodeDirToPATH(opts)) {
    // prefer current node interpreter in child scripts
    pathArr.push(path.dirname(process.execPath))
  }

既然了解了这个参数的作用,这次我们执行:

npm1 run build --scripts-prepend-node-path

warning 的确没了,但是出现了一个报错:

Error: The module '/root/preact/node_modules/iltorb/build/bindings/iltorb.node'
was compiled against a different Node.js version using
NODE_MODULE_VERSION 64. This version of Node.js requires
NODE_MODULE_VERSION 57. Please try re-compiling or re-installing
the module (for instance, using `npm rebuild` or `npm install`).

这里提示说项目的依赖包在安装时候使用的 Node.js 版本与执行 build 的时候的版本不一致。我们最开始安装依赖的时候直接执行的是 npm1 run install,并没有带上 --scripts-prepend-node-path 参数,所以实际安装用的版本是 v10.16.0,而不是想要的 v8.12.0。我们重新带上参数安装并 build 试试:

npm1 install --scripts-prepend-node-path
npm1 run build --scripts-prepend-node-path

可以发现这次成功了,而且用的版本是 npm1 对应的 Node.js v8.12.0

(其实还有一种情况就是,当 npm 版本与 Node.js 版本不兼容时,直接运行 npm 也会报错)

结论

可以看到为了让 npm 能识别指定版本的 Node.js,我们需要改动到 npm-cli.js 中的 shebang。另外当多个版本的 Node.js, npm 同时共存时,还需要注意 npm 的配置文件需要隔离多份并且有些配置不能重复,比如用来放置全局依赖包的 prefix 文件夹路径不能相同,否则也会引起一些问题。鉴于这些问题,如果没有特殊需求,并不推荐同时安装多个不同版本的 Node.js,建议还是使用版本管理工具比较好。如果一定要保持现有的 Node.js 存在的情况下,需要使用另一个版本的 Node.js 来做一些事情,建议利用 docker 的 Node.js 镜像来实现,这样既方便又不怕搞乱现有的环境。

1. jsx-runtime

开始

React v17 增加了 jsx-runtime 机制,摒弃了过去通过 React.CreateElement 创建 React element 的方式。以 <h1>Poem</h1> 为例,在 React v16 及其之前,上面的 JSX 会被转译成:

import React from 'React';
const a = React.createElement("h1", null, "Poem");

而在 React v17 之后,通过正确的 transpiler 配置(以 tsc 为例,compilerOptions.jsx 的值需要是 ReactJSX),会被转译成:

import { jsx as _jsx } from "react/jsx-runtime";
const a = _jsx("h1", { children: "Poem" });

乍一看似乎只是换了个函数,但是有几点需要引起注意。

首先是两个函数的签名。React.createElement 接收的参数为 (type, props, ...children), _jsx 接收的参数为 (type, config, maybeKey)。新的 _jsx 函数在创建 React element 的时候,把 children 属性放进了 props (config) 里,而把 key 独立了出来。
第二个变化是,原先 createElement 是 React 的一个方法,因此在写 JSX 的时候,我们通常还需要通过 import React from 'react' 在当前模块中引入 React。而在 React v17 之后,配合正确的 transpiler 配置,不再需要显示引入 React,transpiler 会自动根据配置引入对应的 jsx 函数。

React.createElement 改成 react/jsx-runtime 带来的好处主要有两个:

  1. 不再显示引入 React 到当前模块。
  2. 因为 _jsxCreateElement 更简短而且更可压缩替换,打包完成之后 JavaScript bundle 的体积将会稍微减少一些。

Warning
React.createElement 方法并没有从代码里去掉,React v17 可以继续向下兼容。更何况我们还可以在代码里手动调用 React.createElement 来创建 React Element。

_jsx 与 _jsxs

再来看一个在项目中非常常用的、动态创建 React element 的例子:

const App = () => {
  return (
    <div className="outer">
      <span>Header</span>
      <div className="inner">
        {[1, 2].map(num => (
          <p>{num}</p>
        ))}
      </div>
    </div>
  )
}

上面的代码会被转译成:

const App = () => {
  return _jsxs(
    'div',
    Object.assign(
      { className: 'outer' },
      {
        children: [
          _jsx('span', { children: 'Header' }),
          _jsx(
            'div',
            Object.assign({ className: 'inner' }, { children: [1, 2].map(num => _jsx('p', { children: num })) })
          ),
        ],
      }
    )
  )
}

不仔细看的话可能会忽略 div.outer 元素是通过 _jsxs 而不是 _jsx 创建的。_jsxs_jsx 的区别在于,前者应该只对 children 为“静态”数组的元素调用,后者则是对元素的 children 属性为非数组或者只为“动态”数组时调用。

这里所谓的静态与动态,指的是子元素是否会在每次渲染时,存在动态排序、增删的情况。很明显,div.outer 具有两个在源码中就有固定顺序的子元素。而 div.inner 的子元素则是通过表达式动态生成的,每次 App 组件渲染时,div.inner 的子元素与上次相比可能交换过了顺序,或者删除、增加了某些子元素。

校验 key

我们都知道 React 要求开发者为每个动态生成的子元素手动增加一个 key 属性,相当于给这些子元素赋予一个固定的 ID,以便能够让 React 能够在同级元素中检测到哪些元素被移位、删除和新增。之所以上面的代码里会需要区别静态与动态子元素,并使用两个不同的方法来生成元素,就是为了校验子元素的 key 属性。

下面简单分析下具体的代码细节。两个函数内部都只调用了 jsxWithValidation 函数,区别在于传入的参数。先看看 jsxWithValidation 函数能接收的参数:(type, props, key, isStaticChildren, source, self)_jsxs_jsx 区别只在于传入的 isStaticChildren 的值,前者是 true,后者是 falsejsxWithValidation 中只在一个地方使用了 isStaticChildren

if (isStaticChildren) {
  if (isArray(children)) {
    for (let i = 0; i < children.length; i++) {
      validateChildKeys(children[i], type);
    }

    if (Object.freeze) {
      Object.freeze(children);
    }
  } else {
    console.error(
      'React.jsx: Static children should always be an array. ' +
        'You are likely explicitly calling React.jsxs or React.jsxDEV. ' +
        'Use the Babel transform instead.',
    );
  }
} else {
  validateChildKeys(children, type);
}

这段代码意图很清晰:如果 children 是静态数组,对 children 中的每个元素进行 key 的校验;如果 children 不是数组,或者是动态数组,那么对整个 children 做校验。validateChildKeys 函数会判断传入的 children/child 是否是数组,如果是数组则会校验数组中的每个元素是否有合法的 key 属性。

Note
在生产环境中,_jsxs_jsx 其实都指向同一个函数:jsxProd, 省略了几乎所有的校验。

React Element

_jsxs_jsx 函数其实更多的只是在开发环境中做一些校验,真正重要的是它的返回值。

前面我提到过几次 React element,这个概念似乎有点抽象又有点跟其他概念混淆。简单来讲的话,React element 就是 React component 的调用返回值。以上面的代码为例,App 是 React component,a 是 React element。

_jsxs_jsx 除了校验一些参数之外,还调用了一个关键函数 jsx (开发环境的话是 jsxDEV。没错,jsxDEVjsx 的主要区别也是会做更多的校验),而 jsx 的返回值就是 React element。

每个 React element 都只是一个带有几个特殊属性的字面量对象而已:

// packages/shared/ReactElementType.js 里的 flow 类型定义

export type ReactElement = {
  $$typeof: any,
  type: any,
  key: any,
  ref: any,
  props: any,
  // ReactFiber
  _owner: any,

  // __DEV__
  _store: {validated: boolean, ...},
  _self: React$Element<any>,
  _shadowChildren: any,
  _source: Source,
};

下面这张图里表示的是 <button onClick={addCount}>Add</button> 对应的 React element 对象:
image

总结

  1. _jsx_jsxs 的区别在于能够校验动态生成的 childrenkey 属性,在生产环境版本的 React 中,两者指向同一个函数。
  2. 理一下调用关系:
graph TD;
    _jsx/_jsxs --> jsxWithValidation --> jsx --> ReactElement
  1. 开发环境调用的函数版本包含很多的校验和错误提示,React 中其他地方的很多函数也是如此,这也是为什么 dev 环境的 React 应用性能比 prod 环境的要差很多。
  2. 函数式组件的 defaultProps 是在 jsx 函数中 merge 到 props 中的:
// Resolve default props
if (type && type.defaultProps) {
  const defaultProps = type.defaultProps;
  for (propName in defaultProps) {
    if (props[propName] === undefined) {
      props[propName] = defaultProps[propName];
    }
  }
}

Quiz

判断下面 console.log 语句输出内容的顺序

const Button = (props) => {
  console.log(2)
  return <button onClick={props.onClick}>Add</button>
}

const App = () => {
  const [count, setCount] = useState(0)
  const addCount = () => {
    setCount(count + 1)
  }
  return (
    <div>
      {console.log(1)}
      Count: {count}
      <Button onClick={addCount} />
      {console.log(3)}
    </div>
  )
}
Answer

顺序为: 1 3 2

其实只要能想象到 JSX 被转译成普通的 JavaScript 代码的样子,就能知道答案。App 组件被转译成:

const App = () => {
  const [count, setCount] = useState(0)
  const addCount = () => {
    setCount(count + 1)
  }
  return _jsxs('div', {
    children: [console.log(1), 'Count: ', count, _jsx(Button, { onClick: addCount }), console.log(3)],
  })
}

这里 _jsx(Button, { onClick: addCount }) 调用完之后只会返回一个 React element 对象,并没有执行 Button 函数。

内容不错或者比较美观的文章

React 17 introduces new JSX transform
JSX.Element vs ReactElement vs ReactNode

理解 MessageChannel

刚刚无意之中在网上看到 web worker 这个词,瞬间跌入了回忆的深渊,什么 serviceWorkerwalking dead、亲爱的长者 乱七八糟一齐涌了出来,就是没有 web worker,最后颓废的发现,原来关于 worker 相关的东西自己已经忘干净了。

不过隐约记得这东西用起来不很复杂,于是果断上 MDN 看看文档,但是又无意中看到 MessageChannel 这个词,很明显幼小的心灵又被一个陌生的词汇狠狠的鞭笞了一下。忍不住点进去看了一下关于 MessageChannel 的资料,发现这行代码 (来源):

otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);

蛤?原来 postMessage 方法可以接收第 3 个参数?那请问第三个参数是什么意思?文章里说的 MessgaePort 对象又是什么?想到这么多问题自己还完全摸不着边,而且问题之间环环相扣,于是仔细深究了一番,下面是一些笔记。

什么是 MessageChannel ?

首先,MessageChannel 是一个构造函数,创建一对相互连接的 MessagePort 对象。

var mc=new MessageChannel;
mc.port1.onmessage=function(e){console.log("port1:"+e.data);};
mc.port2.onmessage=function(e){console.log("port2:"+e.data);};
mc.port1.postMessage("1"); //会在port2的message事件收到
mc.port2.postMessage("2"); //会在port1的message事件收到

(这段代码的来源)

上面说的一对相互连接的 MessagePort 对象分别是 mc.port1mc.port2,他们可以相互给对方发送消息,并且处理接收到的消息。这就是 MessageChannel 最基本的用法,很简单,但是看起来很无用。

postMessage 很好用

我们知道 postMessage 可以用于 worker 和跨文档消息传递机制,用起来也很简单,只要记住 postMessage 是”自己给自己发消息“,我们以后者为例:

// index.html
var iframe = document.querySelector('iframe')
iframe.onload = () => {
  let w = iframe.contentWindow

  w.onmessage = (e) => {
    console.log(e.data)
  }
  w.postMessage('initialize', '*')
}

// iframe.html
onmessage = (e) => {
  console.log(e.data)
  window.postMessage(`Received message: ${e.data}`)
}

上面这段代码逻辑非常简单,iframe 自己给自己发消息并处理接收到的消息,但是可能不好理解的就是所谓的 “自己给自己发消息”。在我们正常的认知中,发消息通常都是一方发给另外一方,哪有自己给自己发消息的 (自言自语除外)。而且因为自发消息的特性,在不同的上下文中 (不同的页面或者 worker 与 main thread 间),postMessageonmessage 在写法上看起来又不像是 ”自己给自己发“:在 index.html 中写法是 iframe.contentWindow.postMessage,在 iframe.html 中写法又是 window.postMessage。总之,如果不习惯的话从文字上看起来会觉得这种写法有些别扭,不过好在我们还有另一种写法。

MessageChannel 让通信变得更简单

上面说到 MessageChannel 构造函数会创建一对相互关联的 MessagePort 对象,怎么理解 MessagePort 对象呢?可以把一个 MessagePort 对象当成消息传递的管道,我们可以通过这两个对象来进行消息的传递,想象一下现实生活中的两个人通过手机打电话,一个 MessagePort 对象就是一个手机。

MessageChannel 其实就更好理解了,不再是自己给自己发消息,而是一方发给另一方,也即是 mc.port1mc.port2 相互传递处理消息。

但是怎么利用 MessageChannel 来让我们现有的消息传递机制更加简单清晰呢?答案正是我们上面说的 window.postMessage 方法的第三个参数 (MessagePort.postMessage 里是第二个参数)。

这个参数是可选的,而且如果有的话必须是 Array/ArrayLike,表示 “跟随信息一起传送的可转让对象的序列”,意思就是把这些可转让对象的所有权转让给目的上下文 (注意只有 MessagePortArrayBuffer 对象可以被转移)

什么意思呢?看文章里的第一行代码:

otherWindow.postMessage('Hello from the main page!', '*', [channel.port2]);

这里 channel.port2 就被转移到 otherWindow 上下文中去了,也就是现在 channel.port2 能够被 otherWindow 上下文访问到了。不过这样有什么用呢?我们不还是用了 postMessage 方法吗?

其实很好理解,想像一下,现在你跟你的一个小伙伴分隔两地,通信是通过邮政寄信的方式,很不方便,但是某天你突然得到两个电话,然后通过邮政发给你的小伙伴一个,这样等小伙伴收到电话你们就能愉快的打电话了。翻译过来就是:通过 postMessage 把其中一个 MessagePort 对象传递给需要通信的一方,然后双方就可以通过 MessagePort 对象相互通信。

好了就说这么多,下面直接上完整的代码,很好理解:

// index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title></title>
</head>
<body>
    <iframe src="iframe.html" frameborder="0"></iframe>
    <script>
        var channel = new MessageChannel
        var port = channel.port1

        var iframe = document.querySelector('iframe')
        iframe.onload = () => {
          iframe.contentWindow.postMessage('initialize', '*', [channel.port2])
        }

        port.addEventListener('message', (e) => {
          console.log(e.data, e)
        }, false)

        // 因为我们用的是 addEventListener 而不是 onmessage,
        // 所以需要调用 port.start 方法
        // 见: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort
        port.start()        
    </script>
</body>
</html>

// iframe.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <title></title>
</head>
<body>
    <script>
      onmessage = (e) => {
        // index.html 传递过来的 MessagePort 对象
        // 通过 e.ports[0] 获取
        var port = e.ports[0]

        // 现在可以愉快的通过 port
        // 进行消息传递
        port.onmessage = (e) => {
          console.log('from iframe: ' + e.data)
        }
        port.postMessage('sad')       
      }
    </script>   
</body>
</html>

总结

MessageChannel 是基本的双向信息传输管道,可以把它想象成 window.postMessage/window.onmessage 的另一种更加清晰的替代方案。最后要说的一点就是,sharedWorker 默认使用了 MessagePort 进行消息传递,有兴趣的同学可以自行探索。

参考链接:

MDN
7 Things You Need To Know About Web Workers
Messing with MessageChannel

整洁的代码 vs 脏乱的代码: React 最佳实践

并不逐字翻译,原文请点这里

这篇文章主要关注如何编写整洁的代码,以及 ES2015 所带来的一些语法糖是如何帮助我们更容易的实现这一点的。

什么才是整洁的代码?为什么我需要关注它?

整洁的代码,说白了就是风格统一的,编写、阅读、维护起来都非常轻松的代码。所以仅仅能够正常工作的代码并不就是整洁的代码。

当你阅读这篇文章的时候就是一个清理“死”代码(dead code)的好机会。你可以重构、删除被注释掉的代码,这一切都是为了更好的维护性。写代码的时候我们就应该想着:我这么写,六个月之后其他人甚至是我自己能看得懂吗?简而言之,整洁的代码就是你写完后可以很自豪的拿回家展示给**看的代码。

作为一个优秀的开发者来说,写整洁的代码是自然而然必须要掌握的东西。

整洁的代码看起来就很舒服

如果一段代码给人的感觉是:“总觉得哪里不对啊”,那么很有可能这段代码的的确确确确实实实实在在是有问题的。好的想法经常会一起出现,不好的也是。所以当你觉得目前正在做的事情就像是要把一个正方形的塞子拼命填进一个圆形的孔里的时候,停下来歇一会,退一步想是不是不应该这么做。90% 的可能你会想出来一个更好的解决办法。(Nine times out of 10, you’ll come up with a better solution)

避免重复

如果你发现有一些类似的代码散落在各个地方,那么考虑提取出一个公共的模式。

// Dirty
const MyComponent = () => (
  <div>
    <OtherComponent type="a" className="colorful" foo={123} bar={456} />
    <OtherComponent type="b" className="colorful" foo={123} bar={456} />    
  </div>
);
// Clean
const MyOtherComponent = ({ type }) => (
  <OtherComponent type={type} className="colorful" foo={123} bar={456} />
);
const MyComponent = () => (
  <div>
    <MyOtherComponent type="a" />
    <MyOtherComponent type="b" />
  </div>
);

尽管编写公共的模式有时候会导致写更多的代码,但是这样也增加了代码的可维护性,利大于弊。需要注意的时候不要在编写公共模式上陷的太深,知道什么时候该停止。

整洁的代码意味着可预测及可测试

单元测试在前端开发中也已经变得越来越重要,甚至不可或缺,所以确保你的代码是可测试的。

整洁的代码是自注释的(self-commenting)

当你某天写了一段不错的代码,为了其他人能够更方便的阅读,你附上了一段详尽的注释。突然有一天你发现了这段代码里有一个 bug,所以你又修改了你的代码,看起来再正常不过。等等,注释你也一起改了吗?你可能记得,也可能不记得了。下一个维护你这段代码的人可能会因为信任你的注释而直接跳过代码,那么他的结局可能会很惨。

仅仅在有需要的地方加上注释,别注释显而易见的东西,这样能减少视觉上的脏乱。

// Dirty
const fetchUser = (id) => (
  fetch(buildUri`/users/${id}`) // 获取用户的 DTO 记录
    .then(convertFormat) // 转换为下划线的形式
    .then(validateUser) // 确保用户是有效的
);

更干净更合理的注释 (注意 convertFormt 转换为了 snakeToCamelCase):

// Clean
const fetchUser = (id) => (
  fetch(buildUri`/users/${id}`)
    .then(snakeToCamelCase)
    .then(validateUser)
);

关于命名

  • 布尔类型的变量,或者当一个函数的返回值为布尔类型时,应该以 is, has 或者 should 开头命名。
// Dirty
const done = current >= goal;
// Clean
const isComplete = current >= goal;
  • 函数应该以它们做什么(what they do)而不是怎么做(how they do it)来命名。换句话说,不要把函数的实现细节暴露在函数名中,因为你很可能某一天会回来修改它的实现方式。
// Dirty
const loadConfigFromServer = () => {
  ...
};
// Clean
const loadConfig = () => {
  ...
};

整洁的代码应该遵守良好的设计模式与最佳实践

这么多年来,程序员们发现了很多能够解决特定问题的方法,也就是我们常说的设计模式。这些设计模式已经被无数次证明能够良好的工作、有针对性的解决某些问题,所以你应该站在巨人的肩膀上,利用好这些设计模式,从而避免自己犯下前人们犯过的错误。

最佳实践跟设计模式类似,但含义更广泛,通常来说并不特指某些算法。比如 “你应该使用 eslint 检验你的代码” 或者 “当写独立的库文件时,你应该把 React 作为 peerDependency” 等这些宽泛的建议。

这里有一些当你编写 React 程序时的最佳实践:

  • 最小原则。使用尽量小的函数,每个函数都只负责单一的功能。这样意味着你可以把一个复杂的组件拆分成多个小的组件,同时也意味着更好的测试性。
  • 不要把模块内部的依赖强加给调用者(consumer)。
  • 遵循严格的验证(lint)格式。这样能帮助你编写整洁的、统一的代码。

整洁的代码并不意味着需要更长时间来编写

我总是听到这样的说法:编写干净的代码会降低生产力。这种说法基本上是胡说八道。俗话说万事开头难,最开始编写整洁的代码的时候可能会比较慢,但是后面会越来越快,因为你需要写的代码行数比脏乱的代码要少。

如果你的代码能够拆分成许多遵守单一原则的细小模块,那么很可能你写完这些模块之后就不用再碰它们了,“写了就忘了它们吧!”

脏乱的代码 vs 整洁的代码

避免重复

// Dirty
import Title from './Title';
export const Thingie = ({ description }) => (
  <div class="thingie">
    <div class="description-wrapper">
      <Description value={description} />
    </div>
  </div>
);
export const ThingieWithTitle = ({ title, description }) => (
  <div>
    <Title value={title} />
    <div class="description-wrapper">
      <Description value={description} />
    </div>
  </div>
);

Thingie 相当于 ThingieWithTitle 组件删除一个 Title 属性后的组件。我们来写一个公共模式:

// Clean
import Title from './Title';
export const Thingie = ({ description, children }) => (
  <div class="thingie">
    {children}
    <div class="description-wrapper">
      <Description value={description} />
    </div>
  </div>
);
export const ThingieWithTitle = ({ title, ...others }) => (
  <Thingie {...others}>
    <Title value={title} />
  </Thingie>
);

默认值

// Dirty
const Icon = ({ className, onClick }) => {
  const additionalClasses = className || 'icon-large';
  return (
    <span
      className={`icon-hover ${additionalClasses}`}
      onClick={onClick}>
    </span>
  );
};

上面给 className 赋默认值的做法在以前非常常见,这里我们可以用 ES2016 的默认值语法来使代码更简洁:

// Clean
const Icon = ({ className = 'icon-large', onClick }) => (
  <span className={`icon-hover ${className}`} onClick={onClick} />
);

简洁多了。不过我们还可以用一个更简洁更 React 的方式来优化:

// Cleaner
const Icon = ({ className, onClick }) => (
  <span className={`icon-hover ${className}`} onClick={onClick} />
);
Icon.defaultProps = {
  className: 'icon-large',
};

为什么说这种方式更整洁?首先,使用 React 的方式设置默认 props 值是一种性能更好的方式,因为默认 props 是基于组件的生命周期的,同时这样也能够使用 React 的 propTypes 检查机制。另外一点,这种方式把赋默认值的逻辑从组件中抽象了出来。

因此你可以把组件的默认属性值放置在一个单独的模块中,然后作为依赖引入。这里并不是建议你去这么做,只是说这种方式带来的弹性完全能够让你有能力这么做。

import defaultProps from './defaultProps';
...
Icon.defaultProps = defaultProps.Icon;

从渲染中分离状态逻辑 (Separate stateful aspects from rendering)

将有状态的数据加载逻辑与渲染逻辑混合在一个组件中会导致这个组件的复杂度增加。你应该分离两者,将数据加载逻辑放置在容器中,而将渲染放置在另一个组件中,从而使这两个组件遵循单一原则。这就是我们所说的容器模式

在下面的这个例子中,用户的数据加载与渲染都在同一个组件中:

// Dirty
class User extends Component {
  state = { loading: true };

  render() {
    const { loading, user } = this.state;
    return loading
      ? <div>Loading...</div>
      : <div>
          <div>
            First name: {user.firstName}
          </div>
          <div>
            First name: {user.lastName}
          </div>
          ...
        </div>;
  }

  componentDidMount() {
    fetchUser(this.props.id)
      .then((user) => { this.setState({ loading: false, user })})
  }
}

再来看看整洁的版本:

// Clean
import RenderUser from './RenderUser';
class User extends Component {
  state = { loading: true };

  render() {
    const { loading, user } = this.state;
    return loading ? <Loading /> : <RenderUser user={user} />;
  }

  componentDidMount() {
    fetchUser(this.props.id)
      .then(user => { this.setState({ loading: false, user })})
  }
}

这里我们按照上述说的容器模式进行了分离。这不仅仅是让代码变得更加易读,也让代码更加可测。由于 RenderUser 是一个无状态的组件,因此它的运行结果是可预测的。

使用无状态的组件 (stateless functional components)

React v0.14.0 引入了无状态的组件,这些组件更加简单,只专注于渲染。比如下面这个组件就非常适合转换成无状态组件。

// Dirty
class TableRowWrapper extends Component {
  render() {
    return (
      <tr>
        {this.props.children}
      </tr>
    );
  }
}
// Clean
const TableRowWrapper = ({ children }) => (
  <tr>
    {children}
  </tr>
);

无状态组件不仅看起来更简洁,性能也会更好,因为这并不需要创建一个完整的实例。

这属于 ES2015 的语法范畴,很简单,举个栗子就好,不赘述了。

Rest/spread 运算符

// Dirty
const MyComponent = (props) => {
  const others = Object.assign({}, props);
  delete others.className;
  return (
    <div className={props.className}>
      {React.createElement(MyOtherComponent, others)}
    </div>
  );
};
// Clean
const MyComponent = ({ className, ...others }) => (
  <div className={className}>
    <MyOtherComponent {...others} />
  </div>
);

对象解构

// Dirty
componentWillReceiveProps(newProps) {
  this.setState({
    active: newProps.active
  });
}
// Clean
componentWillReceiveProps({ active }) {
  this.setState({ active });
}

数组解构

// Dirty
const splitLocale = locale.split('-');
const language = splitLocale[0];
const country = splitLocale[1];
// Clean
const [language, country] = locale.split('-');

结论…

一旦你坚持编写整洁的代码,那么这会自然而然成为你的习惯,你将会很快享受 “写完就忘了吧!” 所带来的各种好处。希望这篇文章对你有所帮助。

归纳证明及递归的应用

TLDR: “要想理解递归则要首先理解递归”,但是这篇文章并不是介绍如何使用递归。相反,更多的是通过利用递归来体验归纳证明所带来的好处。 ps. 最近在看 python 相关的一些东西,因此文章里的示例代码用 python 来实现,简单易懂,应该不会成为阅读这篇文章的障碍。

从数组求和开始

数组求和是非常简单的任务,下面是一段可能顺手就可以写成的求和函数 sum_array:

def sum_loop(S):
    total = 0
    for value in S:
        total += value

    return total

z = [1, 4, 12]
print(sum_loop(z)) # 17

这段代码简洁高效,基于常规思路编写,没有任何问题。

现在让我们换一种思路,考虑几种特殊情况数组(实际上 z 在 python 中叫列表)的求和操作。

首先是不包含任何元素的数组:[],对这类数组求和可以直接返回 0:
image

其次是只包含一个元素的数组:[a],很明显,对只包含一个元素的数组求和,应该直接返回数组中这个唯一的元素,这里是 a:
image

然后是包含两个元素的数组:[a, b],求和的值为 a + b:
image

如果是包含三个元素的数组 [a, b, c] 呢?该如何求和?在回答 a + b + c 之前让我们稍作思考,包含三个元素的数组的和,其实可以拆成两个数组来求和。也就是说 [a, b, c] 的和,等于 [a, b] 求和加上对数组 [c] 的求和。而实际上通过上面的分析,我们已经能够对包含两个元素和只包含一个元素的数组求和了,现在要做的就是把两个数组的求和结果简单的相加。所以我们能够对包含三个元素的数组求和
image

那如果数组包含了四个元素呢?同样,对包含四个元素的数组求和,可以拆解为对一个包含三个元素的数组以及对一个仅包含一个元素的数组分别求和再相加:
image
因为我们已经能够对包含三个元素的数组求和,所以包含四个元素的数组的和也能够计算出来。

依此类推,既然我们能够对包含四个元素的数组求和,说明也能够对包含五个元素的数组求和。如果能对包含五个元素的数组求和,那么就能对包含六个元素的数组求和....... 所以其实际上我们已经能够对包含 n 个元素的数组求和了,这就是归纳证明!

我们把刚刚分析写成对应的代码:

def sum_recursion(S):
    if len(S) == 0:
        return 0

    if len(S) == 1:
        return S[0]

    if len(S) == 2:
        return S[0] + S[1]

    # S[:-1] 相当于是 JavaScript 中的 S.slice(0, -1)
    # S[-1:] 则相当于是 S.slice(-1)
    return sum_recursion(S[:-1]) + sum_recursion(S[-1:])

z = [1, 4, 12]
print(sum_recursion(z)) # 17

为了跟最开始使用循环的求和函数区分,我们给新的函数起名为 sum_recursion。可以看到 sum_recursion 函数体中大部分代码都是对最初分析的包含零个、一个、两个元素的数组进行求和操作,最后一条语句,则是将原数组拆分成两个数组,然后分别再次进行求和,并把各自求和的结果相加。

仔细观察 sum_recursion 函数最后的这条语句:

return sum_recursion(S[:-1]) + sum_recursion(S[-1:])

这里所做的事情,是把原数组拆成两个数组,然后分别递归调用 sum_recursion。第一个数组包含了前 n - 1 个元素,第二个数组只包含最后一个元素,即第 n 个元素。为了能更清晰的看到整个计算过程,我们使用图示来表示对一个包含五个元素的数组求和的过程,也就是 sum_recursion ([a, b, c, d, e]) 的调用栈:
image

可以看到每次调用 sum_recursion 函数的数组都在不断的缩小自身包含的元素数,直至最后只包含两个元素的那个调用。我们把对包含 0、1、2 个元素的数组求和的操作称为基线条件,而求和的整个过程我们要做的最重要的事情就是只管基线条件的计算,剩下的事情就是把数组不断拆解。

使用 sum_recursion 求和的过程仿佛是在说:”我不管这个数组中有多少个元素,我已经告诉 sum_recursion 这个函数怎么对包含 0、1、2 个元素的数组进行求和了。所以接下来我只管把数组不断拆分然后丢给 sum_recursion ,它会计算好所有元素的和给我“。

找最大值

如何用我们刚刚的思路来从数组中找出最大值呢?很简单,我们只需要知道如何从包含 0、1、2 个元素的数组中找出最大值:
image

下面就是从数组中找出最大值的函数实现代码:

def find_max(S):
    if len(S) == 0:
        return None

    if len(S) == 1:
        return S[0]

    if len(S) == 2:
        return S[0] if S[0] > S[1] else S[1]

    left_max = find_max(S[:-2])
    right_max = find_max(S[-2:])

    return left_max if left_max > right_max else right_max

z = [1, 3, 123, 54, 231, 13]
find_max(z) # 231

你可能会想,其实一个循环就能解决数组求和与找最大值问题,为什么还要大费周折写这么多代码来实现呢?仔细想想,在 find_max 函数中,我们做的最复杂的事情,就是从包含两个元素的数组中找出最大值。归纳证明加上递归的应用能够极大的降低我们的心智负担,接下来看看一个稍微复杂的应用场景。

排序

给数组排序是一个经常被讨论的问题,目的很简单,就是把一个乱序的数组排列成非递减的数组。能利用归纳证明的思路来解决排序问题吗?要想知道答案,先从基线条件开始。

如果一个数组不包含任何元素,我们不需要排序,直接返回原空数组:
image

如果数组只包含一个元素,也不需要排序,直接返回原数组:
image

如果数组包含两个元素,通过简单的把第一个元素跟第二个元素做一个对比,就能得到排好序的数组:
image

如果数组包含三个元素,该怎么处理?回想一下,我们现在已经具有了给包含 0、1、2 个元素的数组进行排序的能力,如果能够把包含三个元素的数组拆分成多个包含两个元素的数组,或者仅包含一个元素的数组,然后分别对这些子数组进行排序,再将数组拼接起来不就得到三个元素都排好序的数组了吗?

那么接下来的问题就是,怎么合适的拆分子数组,才能使最后对子数组进行拼接的时候,得到的大的数组是按顺序排列的呢?一个容易想到的方案是,我们以数组的第一个元素 a 作为标准,把小于等于 a 的元素放到一个新数组 S1,把大于 a 的元素统统放到新数组 S2,我们只要对 S1S2 排序,然后通过 S1 + [a] + S2 拼凑成一个新数组,而这个新数组正是符合我们期望排序的数组。

假如我们要排序的数组是 [4, 13, 10],那么对应的会产生:
image

S1S2 分别是一个空数组,及一个包含两个元素的数组,而我们恰好都能够对这两个数组进行排序!

如果命名我们的排序函数为 sort,那么意味着:

# 在 python 中我们能直接使用 + 来拼接两个数组(列表 list)
sort([4, 13, 10]) = sort([]) + [4] + sort([13, 10])

假如我们要排序的数组是 [13, 4, 21] 呢?很明显,S1 = [4], S2 = [21]sort 函数完全能处理只包含一个元素的数组:

sort([13, 4, 21]) = sort([4]) + [13] + sort([21])

现在我们具有了对包含三个元素的数组进行排序的能力,那包含四个元素的数组呢?比如数组 [5, 23, 9, 12]

太简单了:

sort([5, 23, 9, 12]) = sort([]) + [5] + sort([23, 9, 12])

好了,接下来就是归纳证明闪光的时刻:既然我们能对包含四个元素的数组排序,说明我们一定能对包含五个元素的数组排序,因此也一定能够对包含六个元素的数组排序........

根据上面思路实现的 sort 函数代码极其简单,唯一比 sum_recursionfind_max 函数复杂的地方可能就是 sort 函数中多了一个循环,用来将数组拆分成两个子数组:

def sort(S):
    if len(S) < 2:
        return S

    if len(S) == 2:
        return [S[1], S[0]] if S[0] > S[1] else S

    S1 = []
    S2= []
    for i in range(1, len(S)):
        if S[i] <= S[0]:
            S1.append(S[i])
        else:
            S2.append(S[i])

    return sort(S1) + [S[0]] + sort(S2)


z = [23, 4, 78, 123, 56, 12]
print(sort(z)) # [4, 12, 23, 56, 78, 123]

如果说在之前的数组求和及找最大值中使用归纳证明加递归,有种杀鸡用牛刀的感觉的话,那么其在数组排序中的应用是一个极好的例子,完美展现了归纳证明及递归强大的解决问题的能力。使用得当的话,能大大减少我们处理问题的复杂度。

实际上,上面的 sort 函数就是快速排序的一种实现。

分析

我们再来回顾下 sum_recursion 函数。通过代码可以发现,sum_recursion 其实是一个二路递归函数(binary recursion)。也就是说每次调用 sum_recursion 函数,都会引起两个其他递归函数的调用(这里是 sum_recursion 自己),所以对于性能的影响要格外注意。

而通过上面调用栈的示意图我们知道,sum_recursion 调用会引起大约 2n - 3 个自身调用,而每个 sum_recursion 调用又会复制一遍数组, 所以 sum_recursion 的时间复杂度为 O(n²)

我们对 sum_recursion 最后一个语句做一个小小的改动:

return sum_recursion(S[:-2]) + sum_recursion(S[-2:])

注意到了吗?两个 -1 变成了 -2。对应的改变就是,每次拆分数组的时候,把前 n-2 个元素分为一组,第 n - 1n 这两个元素拆分成另一组。相应的,sum_recursion ([a, b, c, d, e]) 的调用栈将变成下面这样:
image

对于 len(S) == n 的数组来讲,sum_recursion(S) 函数调用现在会引起大约 n 个函数的调用,虽然好于之前的 2n - 3,但是复杂度还是 O(n²),远远比普通循环或者递归的求和要慢,因此不要将文章中实现的 sum_recursionfind_max 函数用于实际项目中。

总结

通过合理的利用递归,我们可以很好的实践归纳证明,并且享受它为解决问题所带来的便利。

不生效

浏览器回退那个,我是在本地建了两个html页面,从页面A跳到页面B,然后在B页面的时候点击浏览器的回退,还是跳到了A页面,感觉就是没生效。。我就直接把这个代码复制在页面B上了

如何监听用户点击浏览器后退按钮

突发奇想,能不能监听点击浏览器的后退按钮事件。

  1. 很明显,如果后退是跳转到另一个全新的页面,监听 window.onbeforeunload 即可

实际上有很多事件都会触发 onbeforeunload,比如刷新、点击链接前往新的页面等。这里应该还是在页面加载完成比如 window.onload 事件里使用 history.pushState 推一条记录进栈,同时监听 window.onpopstate 事件,在这个事件监听器里处理回退。其实也就变得跟下面的两个方案差不多了。

  1. 如果整个页面是一个SPA,而且使用的是 pushState/replaceState 来实现的导航,那么当浏览器回退的时候会触发 window 上的 popstate 事件。这时需要监听 window.onpopstate 事件;
  2. 如果整个页面是一个SPA,使用的是基于 hashChange 实现的导航,那么这个时候就比较麻烦。因为页面根据 hash 正常变化跳转(导航)的时候也会触发 popstate 事件,所以不能简单的监听 popstate 事件来做出判断。开始的时候对于这种情况我也没有解决思路,于是带上梯子墙内外找了一通。

果不其然,stackoverflow 上一个问题里的一个答案 (点来点去不小心把链接丢了:cry:) 给了一个思路:因为后退按钮属于浏览器的UI,并不属于任何一个页面,所以如果当鼠标在当前页面外时,监听的 popstate 事件被触发,那么认为用户点击了浏览器的后退按钮 (这里的判断鼠标是否在当前页面里是通过监听 document.onmouseleavedocument.onmouseenter事件);另外答案里还绑定了对 Backspace 键的监听,因为某些浏览器默认当页面焦点不属于输入域时触发的是后退操作。这个方法基本可以满足大多数情况,但是如果用户是通过鼠标侧键 (常见于游戏鼠标) 来实现的后退操作,那么可能就无法监听了。

我一直相信黄天不负有心人这句话在大多数情况下都是可以被验证的,而且也相信自己的运气不会太差,因为我爱笑。好了回到正题,说实话对于怎么解决这个问题我想了整整一下午,最后不知不觉敲下了这么一段代码:

    var detectBack = {

        initialize: function() {
            //监听 hashchange 事件
            window.addEventListener('hashchange', function() {

                //为当前导航页附加一个 tag
                this.history.replaceState('hasHash', '', '');

            }, false);

            window.addEventListener('popstate', function(e) {

                if (e.state) {
                    //侦测是用户触发的后退操作, dosomething
                    //这里刷新当前 url
                    this.location.reload();
                }
            }, false);
        }
    }

    detectBack.initialize();

这样就不用监听鼠标或键盘事件了:grin:。代码很简单,也有详细的注释,这里就不在花篇幅解释了,有疑问的话就请留言吧。

更新 (2016/12/22):

其实前进后退按钮都会触发 popstate 事件,MDN 里解释说每当激活的历史记录发生变化时,都会触发 popstate 事件。

更新 (2019/12/19)

在新版的 Chrome 中 (我测试的版本是 79,具体从哪一版本开始的变更暂不清楚),如果你的页面不是单页应用,而且采用的是在 initialize 方法中手动执行 history.pushState(1, '', '') 推入一条历史记录从而监听历史栈的变动,那么如果用户没在目标页面做任何操作(点击、输入等等),直接点击回退按钮的话,是无法触发 popstate 事件的,也就是说这个方法会失效。

探讨 TypeScript 中的一些概念

TLDR: 这篇文章并不是关于 TypeScript 的入门教程,而是总结我在学习 TypeScript 过程中遇到的一些疑问,和整理一些并不是那么显而易见的概念。如果你在看完 TypeScript 官方文档或者写过不少项目后,还是觉得对 TypeScript 整体有些不甚清晰的点,那么这篇文章或许对你有所帮助。

什么是类型 (Type)?

别误会,我并不是要介绍 TypeScript 中的一些基础语法或者基础类型,这些我相信你在学习 TypeScript 第一天的时候就已经烂熟于胸了。比如我们都知道字符串的类型是 string、 在该用到数字的时候应该限制类型为 number=== 运算符的返回值是 boolean,对类型的理解和使用似乎已经成为了一种本能,但是我还是想给什么是类型下个统一的定义:类型就是一系列值以及你可以对这些值做的事情。比如我们刚刚说的 number 类型其实就是所有的数字加上所有你能对数字做的操作,比如 +, -, *, / 操作符以及能对数字类型调用的方法。

这里可能有点咬文嚼字了,但是通过对类型下一个定义,或者说用一种接近本质的角度来看待类型,能让人在每次看到它们时,有一种更深刻的理解,至少对我是这样。

{}, object, Object 有什么区别?

首先,这三种类型都表示你的值是一个没有任何自定义属性的对象,只从 Object.prototype 继承了基本的方法。意味着 TypeScript 会有以下限制:

let user: object = { name: 'lk' }
user.toString() // correct
user.name // error: Property 'name' does not exist on type 'object'.(2339)

另一方面,如果你之前不了解 {}, object, Object 分别代表哪些值 (回想一下我们上面对类型的定义),下面这段代码可能会让你感觉相当困惑:

let title: {}
title = {} // correct
title = [] // correct
title = 123 // correct

let content: object
content = {} // correct
content = [] // correct
content = 123 // error: Type '123' is not assignable to type 'object'.ts(2322)

title 不是一个空对象吗?为什么可以被赋值成一个数字或者数组?content = 123 的报错似乎很明显,但是为什么 content = [] 又不会报错?

我们知道 JavaScript 中有 7 种原始类型 (primitive type):

  1. string
  2. boolean
  3. number
  4. bigint
  5. symbol
  6. null
  7. undefined

除此之外的类型都称为非原始类型 (non-primitive type),而 object (TypeScript v2.2 新加入的类型)就是用来表示这些非原始类型的。也就是说,如果一个变量的值是 object 类型,那么它可以是任何非原始类型值,比如上面的空对象和空数组,但是不能是原始类型值,比如 123

{} 类型不仅包含非原始类型,还包含除 null | undefined 之外的其他原始类型,这也是为什么把 123[] 赋值给 {} 类型都不会报错。清楚了每个类型所包含的值的范围,也就很好理解上面的代码为什么会有这样的差异了。至于 Object 的话,在行为上跟 {} 基本上是一样的。

新增的 object 类型在某些情况下是有用的,比如用来限定 Object.create 方法的参数类型:

interface ObjectConstructor {
  // ...
  create(o: object | null): any;
  // ...
}

什么时候用 type alias?什么时候用 Interface?

type alias 和 interface 在很多时候都可以相互替换使用,具体什么情况该用哪一个并没有强制的要求。相比直接提供一些使用的建议,我觉得把两者主要的差异点先列出来也许更有必要:

  1. 同一个作用域中同名的 interface 会合并声明 (declaration merging),而同一个作用域同名的 type alias 会报错;
  2. type alias 的右值可以是任何类型,包括原始类型 (比如 string, number) 和类型表达式,interface 只能是对象类型 (shape);
  3. interface 可以继承 (extends) 其他 shape 类型;

注意:上面有提到一个 shape 类型,其实就是非原始类型 object。很多人会误以为 interface 只能继承其他 interface、class 只能 implements interface,但实际上可以 extends 或者 implements 其他任何 shape 类型。

了解了这几个重要的差异之后,我们再回到 type alias 和 interface 的使用场景。一般来讲,使用哪种更多的是个人偏好,不过 type alias 似乎比 interface 要简洁通用一些 (type alias 支持类型表达式比如条件判断)。而如果你准备编写一个公共库,可能还需要仔细考虑库中定义的类型是否允许使用者扩展 (declaration merging)。

有哪些地方可以定义泛型参数?

回忆一下我们用到泛型最多的情况应该是在函数中:

function arrayify<T>(data: T): T[] {
  return [data]
}

这算的上是最简单的使用泛型的场景了。我们除了可以在函数里使用泛型参数来设置约定,还有以下几个场景可以用到泛型:

  1. class Arrayify<T> {}
  2. type Arrayify = <T>(data: T) => T[]
  3. type Arrayify<T> = (data: T) => T[]
  4. interface Arrayify { <T>(data: T): T[] }
  5. interface Arrayify<T> { (data: T): T[] }

例 1 很简单,class 在 JavaScript 中本质上还是函数,所以泛型的使用跟普通函数一致。2、 3 一眼看上去非常类似,只是泛型定义的位置不同。2 中的泛型参数定义在调用签名 (call signature) 前面,而 3 的泛型参数紧跟在 type alias 后面。就这个 Arrayify 例子而言,虽然泛型位置不同,但是 2 跟 3 的效果是一样的,那么这两种定义方式有什么区别?

简单来讲,泛型定义的位置决定了它涵盖的作用域。再举个例子:

type Arrayify = {
  <T>(data: T): T[]
  customProp: string
}

type Arrayify<T> = {
  (data: T): T[]
  customProp: T
}

这个例子应该是非常清晰的,定义在调用签名签名的泛型参数只能用在单个调用签名中,而定义在 type alias 后面的泛型参数可以用在整个 type 中。

例 4、5 没有细讲,是因为 interface 在使用泛型的情况下跟 type alias 是类似的,大家自行脑补就好。

使用泛型时为什么有的时候我不用提供具体的类型,而有时候必须要提供?

以上面的 arrayify 函数举例:

const stringArr = arrayify('hello') // string[]
const ageArr = arrayify({ age: 100 }) // {age: number}[]

我们调用 arrayify 时传入了不同类型的参数,但是并没有显示指定泛型参数 T 的具体类型,这是因为 TypeScript 会根据函数参数来推断泛型的具体类型

再来看看上面的 Arrayify:

type Arrayify = <T>(data: T) => T[]
type StringArr = Arrayify // correct

上面的代码很好理解,只是把 Arrayify 赋值给了另一个 type alias StringArr,这俩是等价的。我们再试试当 Arrayify 的泛型参数是声明在 type alias 后面的情况:

type Arrayify<T> = (data: T) => T[]
type StringArr = Arrayify // error: Generic type 'Arrayify' requires 1 type argument(s).(2314)
type NumberArr = Arrayify<number> // correct

TypeScript 并不允许我们在不提供泛型参数值的情况下直接把 Arrayify 赋值给 StringArr。回想一下上一个问题中我有提到过:“简单来讲,泛型定义的位置决定了它涵盖的作用域”,注意只是“简单来讲”,还没说完呢,实际上泛型定义的位置不仅决定了它涵盖的作用域,还决定了 TypeScript 什么时候会给泛型参数赋予具体类型。如果泛型参数声明在调用签名前,表示函数调用的时候会决定好泛型的具体类型 (我们可以手动指定,也可以让 TypeScript 根据函数参数来推断)。而如果是直接定义在 type alias 后面的泛型参数,那么在使用这个 type alias 时我们必须要手动指定明确的具体类型,因为这种情况 TypeScript 无法帮我们推断出泛型的具体类型。

关于 Arrayify<number> 还有一点要说的是,可以把 Arrayify 理解成一个“函数”,Arrayify<number> 理解成函数调用。每次“调用” Arrayify 时,会生成一个新的、泛型参数绑定到我们传入类型的 type alias。

为什么在构造 Promise 实例时需要明确指定泛型参数的具体类型?

我们先来构造一个简单的 Promise 实例:

let promise = new Promise(resolve => resolve(45))
promise.then(value => value * 2) // error: Object is of type 'unknown'.(2571)

上面这段代码没有类型信息,只是一段普通的 JavaScript 代码,但是在 TypeScript 环境中执行到 value * 2 时却报错了。

要知道具体原因的话我们得看看 Promise 构造函数本身具体的定义是怎样的:

interface PromiseConstructor {
  // ...
  new <T>(executor: (resolve: (value?: T | PromiseLike<T>) => void, reject: (reason?: any) => void) => void): Promise<T>;  
  // ...
}

上面的类型定义来自 TypeScript 内置的 lib.es2015.promise.d.ts 文件,我们省略了一些实例方法,只关注构造函数。可以看到 Promise 构造函数有一个泛型参数,回忆下在上一问题中我们说过:TypeScript 通过函数参数来推断其泛型参数,那么 new Promise(resolve => resolve(45)) 显然是不能提供足够的信息来帮助 TypeScript 推断出泛型参数具体类型的,因为resolve(45) 是函数体中的表达式。

既然 TypeScript 无法从参数里推断出泛型的具体类型,我们在 new 表达式中也没有为泛型指定具体类型,那么 T 的具体类型应该是什么?在 TypeScript v3.7.2 中,T 绑定到了 unknown, 而我测试在 v3.4.4 中 T 则被绑定到了 {}。无论是 unknown 还是 {},TypeScript 都不会让我们执行 value * 2,因为 * 操作数只能是数字类型的 (实际上可以是 any, number, bigint 或者 enum type)。

所以我们必须给 Promise 中的 T 明确指定一个具体类型:

let promise = new Promise<number>(resolve => resolve(45))
// or
let promise: Promise<number> = new Promise(resolve => resolve(45))

如何理解泛型参数运算符 extends?

顾名思义,extends 表示继承,出现最多的场景也是类的继承,比如 class Dog extends Animal。在 TypeScript 中,extends 关键字不仅可以用来继承类,还能用来操作泛型参数:

type UserProp = 'name' | 'gender'

function generateArr<T extends UserProp>(prop: T): T[] {
  return [prop]
}

那这里怎么理解这个 extends 比较合适?T 继承 UserProp?似乎有点不是很直观,其实这里指的是,T 必须是 UserProp 本身或者其子类型,是对 T 的一种限制 (constraint),有种类继承反过来的意思。

因为 T 必须是 UserProp 的子类型,那么形参 prop 必须是 name 或者 gender

d.ts 跟普通的 .ts 文件有什么区别?

.ts 文件不用多说,就是存储 TypeScript 常规代码的文件扩展名。那么 d.ts 文件又是用来做什么的?其实是用来给 JavaScript 代码添加类型的。TypeScript 允许我们在 .ts 里导入外部的 JavaScript 模块,但是 JavaScript 本身并没有 type 信息,意味着 TypeScript 没办法帮我们安全地校验模块的使用方式,所以需要有一种方式来给 JavaScript 代码添加类型信息。为了搞清楚应该如何给 JavaScript 添加类型信息,我们可以利用 TypeScript 在编译 .ts 文件的时候能够自动生成对应 d.ts 文件的特性看看 d.ts 文件的具体内容。

假设我们要编译的模块是 util.ts:

const greetingLevel = 1
export function greeting(name: string) {
  return `hello ${name}`
}

tsconfig.json 中,开启编译选项 declaration,告诉 TypeScript 在将 util.ts 编译成 JavaScript 代码时,自动生成 d.ts 文件,里面包含对应的类型信息:

{
  "compilerOptions": {
    // ...
    "target": "es2015",
    "module": "esnext",
    "declaration": true,
    // ...
  }
}

在执行 tsc 之后,我们可以看到 util.ts 同级目录中会多出两个文件:util.jsutil.d.tsutil.jsutil.ts 编译后的 JavaScript 代码,而 util.d.ts 就是 util.js 对应的类型信息。

打开 util.d.ts 可以看到里面的内容如下:

export declare function greeting(name: string): string;

语法看起来跟普通的 TypeScript 代码一样,不过只有类型,没有值。可以理解为 util.d.ts 就是 util.ts 的代码减去值,只保留了类型信息。utils.ts 中除了 greeting 函数外还声明了一个模块内的局部变量 greetingLevel,但是这个变量并没有出现在 util.d.ts 中。这是因为 util.ts模块模式 (module mode),所以对应的 d.ts 只会包含模块导入导出的类型信息,而局部变量的类型会被省略。这很好理解,毕竟模块的局部变量无法在模块外访问,所以自然也没有必要把局部变量的类型信息包含在 d.ts 中。我们还注意到 export 后面出现了一个新的关键字 declaredeclare 用来表示一个断言:在相应的 JavaScript 模块中,一定导出了一个函数 greeting,它的类型是 (name: string) => string。注意只有在编写类型信息时才会用到 declare 关键字。

这里总结一下,d.ts 是用来给 JavaScript 添加类型信息的,所以我们能够在 TypeScript 项目中安全的使用 JavaScript 模块。如果项目都是 TypeScript 代码,那么基本上不会用到 d.ts 文件,因为 .ts 文件本身就包含类型。但是如果我们某些依赖的模块是用 JavaScript 写的,并且没有对应的 d.tsDefinitelyTyped 中也没有第三方贡献的 type 模块,这个时候可能需要我们自己在项目中新建一个 d.ts 文件,为这些 JavaScript 模块增加相应的类型。

什么是三斜杠指令 (Triple slash directive)?什么时候我该用它们?

三斜杠指令只是一种特殊的 JavaScript 注释,以 /// 开头,紧接着一个 XML 标签,比如 <reference lib="dom" />。三斜杠指令一般用来为某一文件制定特殊的编译选项,或者指示某一文件依赖其他文件。还有一类是跟 AMD 模块相关的指令,比如 <amd-module name="CustomModuleName" />

那么你什么时候会用它们?你不用。是的,你几乎永远都不会用到三斜杠指令,所以这里也不再讨论每个指令的具体含义。反过来讲,如果你发现自己好像必须要用到某个指令,最好想想目前的编码模式是不是有问题。

总结

TypeScript 出现的目的是给 JavaScript 附加一套完整的类型系统,但是早期的 JavaScript 并没有完备的规范,所以对一些问题衍生出了很多不同的解决方案,比如模块系统。TypeScript 为了兼容现有的 JavaScript 生态,使自身也增加了很多除普通类型系统之外的东西,例如兼容各类模块规范、给 JavaScript 代码编写类型、扩展模块类型信息等等。正是因为 TypeScript 在语法和使用方面细节很多,官方文档难免会有些遗漏,所以希望这篇文章能做到一个不错的补充。

一段画蛇添足的代码 °(°ˊДˋ°) °

前两天因为某个功能写了这么一段代码:

            /*...*/
            var parseStr = function(str) {
                    if (str != null && (typeof str['split'] === 'function')) {
                        var _t1 = str.split(','),
                            i, _t2;
                        for (i = 0; i < _t1.length; i++) {
                            [].splice.bind(_t1, i, 1).apply(_t1, _t1[i].split(','));
                        }
                        _t2 = _t1.filter(function(t) {
                            return t.search(/\S/) > -1;
                        });
                        return _t2;
                    } else {
                        throw {
                            msg: 'parseStr 参数需要是字符串或者字符串对象'
                        }
                    }
                }
                /*...*/

看上去是不是感觉很乱?还有中文逗号是什么鬼?没有类型声明的语言代码看起来确实麻烦,而且还没有注释,毕竟我喜欢写代码不写注释,但是最讨厌别人写代码不写注释:laughing:。开个玩笑,平常写代码的时候建议大家还是多写点注释,否则为了省事最后麻烦的还是自己。

说了这么多,其实当时想要实现的就是对于任何一段字符串,以中英文 (全/半角) 的逗号来分隔成数组,好进行下一步处理,然后才写了这么一段看上去唬人的代码。突然有一天回过头来看代码的时候想起来字符串的split方法是支持正则表达式的,那为何不改用split呢?TC39说split方法的参数要支持正则的形式,于是有了这段代码:

            /*...*/
            var parseStr = function(str) {
                    if (str != null && (typeof str['split'] === 'function')) {
                        var arr;
                        arr = str.split(/,|,/);
                        return arr;
                    } else {
                        throw {
                            msg: 'parseStr 参数需要是字符串或者字符串对象'
                        }
                    }
                }
                /*...*/

所以最开始的那一大坨代码就相当于 str.split(/,|,/) 这一行要完成的功能。感觉自己这一次很好的诠释画蛇添足这个成语的精髓:sweat_smile: !

使用 Stream

并不逐字翻译,原文请点这里

可读数据流

这篇文章会继续讲述如何建造我们的家用自动监控系统。 在我们的系统里有一个温度计,它会频繁的发出温度数据,就好像是一个数据水龙头一样,而这在 Node.js 里通常被称为可读数据流。

我们可以这样监听温度计发出的数据:

var thermometer = home.rooms.living_room.thermometer;

thermometer.on('data', function(temperature) {  
  console.log('Living room temperature is: %d C', temperature);
});

你可以看到可读数据流其实是事件发射器(event emitter)的一个特例,当有可用数据的时候不断发出 data 事件

一个可读数据流可以发出任何类型的数据: 二进制形式的 buffer 或者字符串,甚至是更复杂的 Javascript 对象。

除了能发出数据,可读数据流还能暂停和恢复:

thermometer.pause();

// in 5 seconds, resume monitoring temperature:
setTimeout(function() {  
  thermometer.resume();
}, 5000);

当暂停的时候,可读数据流将不会再发出数据,直到被恢复流动(resumed)。

很多接口和实例都实现了可读数据流,有些是 Node.js 内置实现的,有些是外部实现的:

  • 读取文件内容
  • 服务器 HTTP 请求体
  • TCP 连接
  • 客户端 HTTP 响应体
  • 数据库变更
  • 音/视频流
  • 数据库查询返回结果
  • 当然还有很多...

创建一个可读数据流

创建一个可读数据流有很多方法,其中有一个是继承 Node.js 的 stream.Readable 类,并实现 _read 方法:

var Readable = require('stream').Readable;  
var util = require('util');

module.exports = Thermometer;

function Thermometer(options) {  
  if (! (this instanceof Thermometer)) return new Thermometer(options);
  if (! options) options = {};
  options.objectMode = true;
  Readable.call(this, options);
}

util.inherits(Thermometer, Readable);

Thermometer.prototype._read = function read() {  
  var self = this;

  getTemperatureReadingFromThermometer(function(err, temperature) {
    if (err) self.emit('error', err);
    else self.push(temperature);
  });
};

Thermometer 构造函数通过调用父类的构造函数来初始化,同时设定 options.objectMode = true 来让可读数据流能够处理除字符串和 buffer 之外的数据类型。

除了继承 stream.Readable 之外,我们的自定义可读数据流类还必须实现 _read 方法,以便当数据流准备好的时候拉取数据。这个方法会从底层资源获取必要的数据,而一旦获取到数据,应该使用 stream.push(data) 将数据推到流中 (译者注:_read 方法不应该直接调用,应该是定义在子类上,并且由内部类方法自动调用)。

拉取(pull)数据流 vs 推送(push)数据流

有两种主要的可读数据流: 有一种是你必须主动去它那拉取数据,我们称之为拉取数据流,另一种是它会推送数据给你,我们可以称之为推送数据流。对于推送数据流的类比是水龙头: 一旦你打开它,就会有源源不断的水流。而对于拉取数据流的类比是一根吸管: 很明显只有你去洗的时候才会有水上来。

以现实生活中的另一个例子来类比,多人传递水桶救火的场景大家应该都熟悉,如果后面没有人来接力这个水桶,那么拿着桶的人只能原地干等着。

Node.js 核心的 stream 类这两种模式都有。如果你只是简单的监听 data 事件,那么推送模式就会被激活,并且数据流会尽可能快的流动起来,速度取决于底层资源推送的速度:

var Thermometer = require('./thermometer');

var thermomether = Thermometer();

thermomether.on('data', function(temp) {  
  console.log('temp:', temp);
});

相反,如果你主动的从 stream 读取数据,那么你使用的就是默认的拉取模式,并且读取的速率由你自己控制,比如下面这个例子:

var Thermometer = require('./thermometer');

var thermometer = Thermometer({highWaterMark: 1});

setInterval(function() {  
  var temp = thermometer.read();
  console.log('temp:', temp);
}, 1000);

客户端使用 stream.read 方法来从 stream 里读取最新的数据。注意我们实例化 thermomete 的时候传入了参数 highWaterMark 等于 1,当 stream 的 objectMode 等于 true 的时候,这个值表示的是 stream 最多能缓存多少个对象。由于我们不想获得老旧的温度数据,所以这里定义了最多只缓存一个数据。

当我们后面介绍能串接多个 stream 的 stream.pipe 方法时,你会清楚这些值以及数据流向是怎么结合在一起的。

可写数据流

相较于发出数据的数据流,我们还有一种能接收数据的数据流: 可写数据流。Node.js 里有一些实例就是可写数据流:

  • 处于添加模式的可写文件
  • TCP 连接
  • 标准输出 (stdout)
  • 服务器 HTTP 响应体
  • 数据库中的库或者表
  • HTML 解析器
  • 远程的日志输出 (remote logger)
  • 当然还有很多其他的...

要往可写数据流中输入数据,可以简单的调用 stream.write(o),传入你想写入数据即可。你也可以传入一个回调函数,当数据被写入的时候调用: stream.write(payload, callback)

与实现自定义的可读数据流类似,实现自定义的可写数据流需要继承自 stream.Writable,并且实现受保护的 stream._write 方法。

现在假如你想把读取到的每一个温度数据都存进数据库中,但是你使用的数据库连接模块并没有提供流式 API,那么我们就来自己实现一个:

var Writable = require('stream').Writable;  
var util = require('util');

module.exports = DatabaseWriteStream;

function DatabaseWriteStream(options) {  
  if (! (this instanceof DatabaseWriteStream))
    return new DatabaseWriteStream(options);
  if (! options) options = {};
  options.objectMode = true;
  Writable.call(this, options);
}

util.inherits(DatabaseWriteStream, Writable);

DatabaseWriteStream.prototype._write = function write(doc, encoding, callback) {  
  insertIntoDatabase(JSON.stringify(doc), callback);
};

实现这样一个可写数据流真的很简单: 除了继承的套路和开启 ObjectMode 之外,只需要再定义一个 _write 方法,这个方法会把数据写入底层资源(这个例子是写进数据库中)。_write 方法必须接受三个参数:

  • chunk: 需要写入的数据;
  • encoding: 如果数据是字符串,则定义其编码;
  • callback: 当数据写入完成或发生错误时的回调函数;

现在我们可以使用这个自定义的类来把温度数据写进数据库中:

var DbWriteStream = require('./db_write_stream');  
var db = DbWriteStream();

var Thermometer = require('./thermometer');

var thermomether = Thermometer();

thermomether.on('data', function(temp) {  
  db.write({when: Date.now(), temperature: temp});
});

看起来不错,但是回过头来想想,我们为什么要费劲为数据库写一个可写的数据流类?为什么不直接使用本来的数据库连接模块?因为 stream 能通过 stream.pipe 方法组合串接起来。

使用管道串接流

相比于手动的链接两个流,我们可以使用 stream.pipe 方法把可读数据流串接到可写数据流上,比如这样:

var DbWriteStream = require('./db_write_stream');  
var db = DbWriteStream();

var Thermometer = require('./thermometer');  
var thermomether = Thermometer();

thermomether.pipe(db); 

上面的代码会保持 thermomether 与数据库之间的数据流动。假如后面我们想停止数据流动,只需要调用 unpipe 方法解除彼此的连接即可:

// 10 秒后解除连接

setTimeout(function() {  
  thermometer.unpipe(db);
}, 10e3);

pipe 方法的好处不仅可以让我们少写数据从一个流转移到另一个流的冗余代码,还能随时让我们控制数据的流动性(暂定/恢复)。

数据流中的流控制 (Flow controll in streams)

当你使用 readable.pipe(writable) 把一个可读数据流跟另一个可写数据流串联起来的时候,数据流动的速率会根据消费者(consumer)的吸收的速率来调整(译者注: 这里的消费者就是可写数据流)。底层的机制是,pipe 会使用可写数据流的 write 方法,并且根据这个方法的返回值来决定是否暂定可读数据流的方法: 如果 write 方法返回 true,表示数据已经被写入底层的数据目的地了(在这个例子里数据目的地就是数据库),而如果返回 false,表示要被写入的数据正在被缓存,等待被写入,也就意味着数据源需要暂停发送数据。一旦可写数据流中的数据已经被排干净了(drained),它就会适当的发出 drained 事件,从而通知管道回复数据流动。

我们之前也说过,你可以通过定义 options.highWaterMark 的值来控制可读数据流最大的缓存值。如果是二进制流,那么这个值的单位是字节(byte), 如果 options.objectModetrue,那么这个值表示的是最多能缓存的对象的数量。

事实上你不用担心这个问题,当你创建一个可写数据流的时候,你只需要在数据已经被写入完成的时候调用回调函数就行了,而当你使用可读数据流的时候,可以通过设定 options.highWaterMark 来设定最大的缓存值,剩下的其他事情 Node.js 都会帮你搞定。

转换数据流

我们已经看过可读和可写数据流了,除了这两种数据流之外,还有第三种数据流: 组合了这两种数据流的数据流,有时候也被称为转换数据流(transform stream)。

数据流其实并不一定成对使用,例如我们上面说的可写数据流通过管道串接至可写数据流,它们也能通过转换数据流来组合使用: 数据从可读数据流流向一个或多个转换数据流,最后写入可写数据流。

在上面给数据库写入数据所创建的可写数据流例子中,我们接收 Javascript 对象,然后把它们转换成 JSON 字符串,最后再插入数据库中。其实我们也可以创建一个通用的转换数据流来做这个:

var Transform = require('stream').Transform;  
var inherits = require('util').inherits;

module.exports = JSONEncode;

function JSONEncode(options) {  
  if ( ! (this instanceof JSONEncode))
    return new JSONEncode(options);

  if (! options) options = {};
  options.objectMode = true;
  Transform.call(this, options);
}

inherits(JSONEncode, Transform);

JSONEncode.prototype._transform = function _transform(obj, encoding, callback) {  
  try {
    obj = JSON.stringify(obj);
  } catch(err) {
    return callback(err);
  }

  this.push(obj);
  callback();
};

为了构建一个自定义的准换数据流,我们需要继承 Node.js 的 stream.Transform 伪类,并且实现受保护的 _transform 方法,而这个方法正是起到实际转换作用的方法。

你可能已经注意到当我们介绍管道(pipe)的时候,我们只是把温度存到了数据库中,并没有把时间戳也一并存进去。现在我们可以自定义一个转换数据流来实现这个方案:

var Transform = require('stream').Transform;  
var inherits = require('util').inherits;

module.exports = ToTimestampedDocTransform;

function ToTimestampedDocTransform(options) {  
  if ( ! (this instanceof JSONTransform))
    return new JSONTransform(options);

  if (! options) options = {};
  options.objectMode = true;
  Transform.call(this, options);
}

inherits(ToTimestampedDocTransform, Transform);

ToTimestampedDocTransform.prototype._transform = function _transform(temperature, encoding, callback) {  
  this.push({when: Date.now(), temperature: temperature});
  callback();
};

做完这个我们就没有必要去创建文档(document, 译者注: 指的是之前存到数据库中的对象)了, 简化了数据库可写数据流:

var Writable = require('stream').Writable;  
var util = require('util');

module.exports = DatabaseWriteStream;

function DatabaseWriteStream(options) {  
  if (! (this instanceof DatabaseWriteStream))
    return new DatabaseWriteStream(options);
  if (! options) options = {};
  options.objectMode = true;
  Writable.call(this, options);
}

util.inherits(DatabaseWriteStream, Writable);

DatabaseWriteStream.prototype._write = function write(doc, encoding, callback) {  
  insertIntoDatabase(doc, callback);
};

function insertIntoDatabase(doc, cb) {  
  setTimeout(cb, 10);
}

最终我们可以实例化并且使用管道串接所有这些数据流:

var DbWriteStream = require('./db_write_stream');  
var db = DbWriteStream();

var JSONEncodeStream = require('./json_encode_stream');  
var json = JSONEncodeStream();

var ToTimestampedDocumentStream = require('./to_timestamped_document_stream');  
var doc = ToTimestampedDocumentStream();

var Thermometer = require('../thermometer');

var thermometer = Thermometer();

thermometer.pipe(doc).pipe(json).pipe(db); 

因为 pipe 方法会返回目标数据流,所以这里我们可以使用链式调用。

第三方数据流

除了 Node.js 内置的数据流之外,NPM 上还有许多实现了转换数据流的包,你可以自行使用这些包来编码,解码,过滤或者对你的数据做任意的转换。

NPM 上还有许多包是导出流式 API 的(比如数据库,websocket 服务器,websocket 客户端等等),你可以安装,创建并且使用管道串接它们,就像搭乐高积木一样。

来吧,快活吧!

关于输入验证的一些想法

TL;DR; 在 Redux 中间件的启发下,实现一个高灵活性的输入验证流。

最近突然对用户的输入验证有了一些想法。

对用户的输入验证我们应该很熟悉,毕竟在提交表单的时候尤为常见。通常来说验证用户的输入并不是问题,表单内置的验证 已经可以较好的完成这个功能,但是当一个应用存在很多表单组件,而且我们想定制验证的流程与界面,每个表单的验证规则又都存在交叉时,会让验证变得比较棘手,也很容易产生一些冗余的代码。

试想一下,一个页面存在三个 input[type=text] 的输入框,分别命名为 A, B, C。

  1. A 的验证规则是:不为空、只能是英文字母数字或者下划线、长度不限制;
  2. B 的验证规则是:不为空、只能是英文字母数字或者下划线、长度不超过 20 个字符;
  3. C 的验证规则是:可以为空,也可以为任意字符,但是长度不超过 30 个字符;

为了分别验证三个表单的输入我们可能需要写三个验证函数:

// 注意 u 修饰符用来正确处理代码点大于 \uFFFF 的字符

function validateA(str) {
  // 不为空、只能是英文字母数字或者下划线、长度不限制;
  return /^[\w_]+$/.test(str.trim())
}

function validateB(str) {
  // 不为空、只能是英文字母数字或者下划线、长度不超过 20 个字符;
  return /^\w{1,20}$/u.test(str.trim())
}

function validateC(str) {
  // 可以为空,也可以为任意字符,但是长度不能超过 30 个字符;
  return /^.{0,30}$/u.test(str.trim())
}

这三个函数并不复杂,似乎就算是页面里再多几个类似这样的输入框,同步增加几个验证函数也能写的这么很愉快。但是仔细想想,正则表达式语义非常不清晰,稍微复杂一点的表达式通常都难以阅读,而且关键的点是输入框 A,B,C 的验证规则都相互交叉:A 跟 B 有 不为空、只能是英文字母数字或者下划线的规则,而 B 跟 C 都有关于长度限制的验证,那我们是否能把公共的验证逻辑提取出来呢?

答案是完全可以的。不过在提取之前我们先设想一下理想情况下验证用户输入的代码应该是什么样子: 我们希望每个验证的因素都是一个函数,接收一个对象作为参数,这个对象包含了需要被验证的字符的信息。每个验证函数需要对输入的数据进行处理,如果验证通过,则交由下一个验证函数处理,如果验证不通过,则返回一个新的对象,同时返回的对象里有一个 reason 属性,代表验证不通过的文字描述。代码可能如下:

const result = validate(
  shouldLteLen(20),
  shouldBeAlphanumeric(),
)('123456789')
// result = { str: '123456789' }
// result 没有 reason 属性,表示验证通过
const result = validate(
  shouldLteLen(10),
  shouldBeAlphanumeric(),
)('abc123456789')
// result = { str: '123456789', reason: '长度不能超过 10 个字符' }
// result 包含 reason 属性,表示验证不通过

我们上面的代码语义非常清晰,甚至在不需要阅读每个函数源代码的情况下,只通过阅读函数名,就能知道验证了什么。而且因为函数最后返回了 reason 这个对错误的文本描述,我们可以很方便的直接把错误信息提示给用户。

接下来就是实现 validate 函数与 shouldLteLen 这种验证函数了。
其实我对上面函数的想法在一定程度上是受 Redux 中间件的启发。这里我们也直接利用 Redux 中的 compose 函数来实现 validate 函数:

function validate(...funcs) {
  return str => {
    return compose(...funcs)(arg => arg)({ str })
  }
}
function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

所以每个验证函数的写法也跟 Redux 中间件的写法类似:

function shouldLteLen(len) {
  return next => source => {
    const { str } = source
    if (str.trim().length <= len) {
      return next(source)
    }
    return {
      str,
      reason: `不能超过 ${len} 个字符`,
    }
  }
}

function shouldBeAlphanumeric() {
  return next => source => {
    const { str } = source
    if (/^\w$/g.test(str.trim())) {
      return next(source)
    }
    return {
      str,
      reason: '必须是数字, 字母和下划线',
    }
  }
}

通过串联验证的方式,我们可以非常方便的增减验证规则。比如我们想在上面验证的基础上增加一个必须存在下划线的规则,可以再定义一个函数 shouldContainsUnderscore:

function shouldContainsUnderscore() {
  return next => source => {
    const { str } = source
    if (/_/.test(str)) {
      return next(source)
    }
    return {
      str,
      reason: '必须包含下划线',
    }
  }
}

实际使用的时候则添加为 validate 函数的参数即可:

const userNameValidator = validate(
  shouldLteLen(10),
  shouldBeAlphanumeric(),
  shouldContainsUnderscore(),
)
const { reason } = userNameValidator(userName)

if (reason) {
  alert(`用户名${reason}`)
}

可以看到我们使用了搭积木一样的模式来对表单输入进行验证,高可定制化。实际项目中可以有若干个很小的验证中间件,通过组合的方式将它们整合为大的完整的输入验证。使用这种模式几乎可以实现在不修改旧代码而只新增代码(验证中间件)的基础上实现验证规则的增加。


68b28a5cc0ea0b084a45068927b7a6eb

Web Component

前言

Web Component 可以看作是一种为了重用界面组件的基于浏览器的技术方案。
主要由Shadow DOM、Tempaltes 标签、自定义元素、HTML 导入(Imports) 这四部分标准组成,
但是要注意这四个标准每个都可单独使用,不一定结合在一起,但是就跟葫芦娃一样只有组合一起使用才能发挥出最大的效果,Web Component。
这篇文章先分别介绍四个标准,然后组合一起实现一个 Web Component 的完整例子,快上车。

Shadow DOM

或多或少在工作当中会有这样的体验:

  • 我们在给元素起 id 名时总要小心翼翼的,生怕一不留神就重复了,导致一些难以追踪的问题出现。
  • 给元素起 class 名也是一样的,当单个页面变得很复杂而为了避免重复,本来一个好好的 <div class='title'> 很可能就变成了 <div class='title-level-2'> (这个问题其实 css-modules 是个不错的解决方案)。
  • 或者有时候我们希望其他的样式和脚本根本就不要操作页面中的一些元素,因为这些元素只会出现在特定的场景中,并不希望任何人的脚本或者样式会影响到它们。

还好有了 Shadow DOM,它也正是封装 Web Component 的关键要素。标准给 HTMLElement 实例添加了一个 attachShadow 方法,利用这个方法,我们可以把 Shadow DOM 附加到一个已经存在的 HTML 元素上。

<html>
  <head></head>
  <body>
    <p id="hostElement"></p>
    <script>
      const host = document.querySelector('#hostElement')
      
      // attachShadow 返回一个 shadowRoot 对象,表示的是
      // 元素里 DOM 子树的 root 元素。这个 DOM 子树与文档树是分离的。
      // mode 表示封装模式,open 表示可以通过 element.shadowRoot
      // 获取上面说的 DOM 子树的 root 元素,另一个可能的参数是 closed。
      const shadowRoot = host.attachShadow({ mode: 'open' })

      shadowRoot.innerHTML = '<span>Hello World</span>'
      shadowRoot.innerHTML += '<style>span { color: red; }</style>'
    </script>
  </body>
</html>

ShadowRoot 里的元素会作为实际渲染的元素显示出来,这样 p#hostElement 会显示为红色的 Hello World,而且外面的样式不会影响里面的,里面的样式也不会影响外面的元素。

Template 标签

浏览器新增了一个 template 标签,写在标签里的任何子元素浏览器都不会去渲染也不会去解析,直到你通过脚本的方式让浏览器去"激活"这部分内容。

<template>
  <style type="text/css">
    div {
      border: solid 1px black;
      width: 100px;
      height: 100px;
    }
  </style>
  <script>
    alert(123)
  </script>
</template>
<div></div>
<script>
  setTimeout(() =>
    document.body.appendChild(document.importNode(document.querySelector('template').content, true))
  , 2000)
</script>

如果你运行这段代码,最开始浏览器什么都不会有,过两秒后会 alert 一个 123,然后页面才会出现有黑色边框的正方形。这里涉及一个重要的属性,就是 templateElement.content 的属性,它的值是 Document Fragment 类型的对象。

其实 template 元素经常会跟 slot 标签搭配使用,这个我们待会再讲。

自定义元素

<x-product data-name='web-component'></x-product>

<script>
  
  class XProduct extends HTMLElement {
    constructor() {
      super()

      const shadowRoot = this.attachShadow({ mode: 'open' })
      const p = document.createElement('p')

      // 获取自定义元素上的 data-name 属性值
      p.textContent = this.getAttribute('data-name')

      shadowRoot.appendChild(p)
    }
  }

  customElements.define('x-product', XProduct)
</script>

直接上代码会好解释很多,自定义元素首先当然需要继承自 HTMLElement 类,constructor 方法是在元素被创建或者更新的时候调用的。我们通过调用 attachShadow 方法让 Shadow DOM 附着到自定义元素上。然后又动态创建了一个 p 元素,其内容是自定义元素上的 data-name 属性值。最后调用 customElements.define 方法完成自定义一个 HTML 元素 (不能多次注册同一标记,浏览器了解某一新标签后也不能再撤回了)。另外需要注意标准要求自定义元素名需要有一个连字符,而且使用的时候不能自我封闭,必须编写封闭标签(<x-product></x-product>)。

扩展 HTMLElement 可确保自定义元素继承完整的 DOM API,并且添加到类的任何属性与方法都会成为元素 DOM 接口的一部分。

一定需要在 constructor 里调用 this.attachShadow 方法吗?当然不是必须的,这里只是稍微演示了一下 Shadow DOM 跟自定义元素结合在一起会发生什么。你可以直接写:

constructor() {
      super()
      const p = document.createElement('p')

      // 获取自定义元素上的 data-name 属性值
      p.textContent = this.getAttribute('data-name')

      this.appendChild(p)
    }

这样会直接把 p 标签插入到 x-product 元素中,页面的显示效果跟上面是一样的,但是这样就没 Shadow DOM 的优势了。

Web Component

上面的代码可以看到我们是手动创建了一个 p 标签,然后插入到 shadowRoot 中的,如果有很多元素的话我们不可能每个都手动创建再插入,这个时候就该用到 template 标签了。正如我们最开始说的,虽然这些标准可以独立使用,但是结合在一起的话会发挥出更大的作用。下面看一个比较完整的例子:

<html>
  <head></head>
  <body>
    <style type="text/css">
      p {
        color: red;
      }
    </style>
    <template id='x-product-template'>
      <style type="text/css">
        div {
          border: solid 1px #ccc;
          width: 200px;
          height: 200px;
        }
      </style>
      <div>
        <span id='name'></span>
        <slot name='fill-text'>None</slot>
      </div>
    </template>

    <x-product data-name='web-component'>
      <p slot='fill-text'>Hello world</p>
    </x-product>

    <script>
      
      class XProduct extends HTMLElement {
        constructor() {
          super()

          const shadowRoot = this.attachShadow({ mode: 'open' })

          const content = document.getElementById('x-product-template').content
          content.getElementById('name').textContent = this.getAttribute('data-name')

          shadowRoot.appendChild(document.importNode(content, true))

        }
        connectedCallback() {
          console.log('connected')
        }
        disconnectedCallback() {
          console.log('disconnected')
        }
      }

      customElements.define('x-product', XProduct)
    </script>
  </body>
</html>
  • slot,槽的意思。其作用看代码就很清晰了,它可以有个 name 属性及预定义元素。当在自定义元素中有其他子元素时,子元素可以有个 slot 属性,表示想匹配替换哪个 slot 元素。
  • 自定义元素其实除了必不可少的 constructor 方法之外,还可以定义其他几个原型方法,比如这里的 connectedCallback 表示当元素被插入到文档中会触发的回调,disconnectedCallback 表示元素从文档中移除时触发的回调等等。

HTML 导入(Import)

再想象一个场景:你依照 Web Component 标准开发了一个功能完备又炫酷的组件,恰好这个组件很多人都需要用,难道这个时候大家只能把 template 和你写的脚本都复制粘贴到每个页面里吗?

显然这很不优雅,所以 HTML Import 就出现了。

<head>
  <link rel="import" href="/path/to/imports/stuff.html">
</head>

link 标签的 rel 设置为 importhref 指向的是任意页面元素 (HTML/CSS/JS) 组成的文件,一行简单的代码就能将你的组件导入到页面里。href 当然可以是网络上的 URL,不过其他域的资源需要允许 CORS。

如果想引入某个组件到你的页面里,一般来说这么用就够了,但是 HTML Import 所包含的东西却远没这么简单,下面再稍微介绍一些其他的特性。

如果想访问导入的内容,通过 link 元素的 import 属性:

const content = document.querySelector('link[rel="import"]').import

导入的内容并不在主文档中,仅仅作为主文档的附属存在,但是导入中的脚本会在包含导入文档的 window 上下文中运行。因此你可以将导入中的 HTML 元素或者样式加入到当前文档中。

<style type="text/css">
  div {
    color: red;
  }
</style>

<script>
  // importDoc 是导入文档的引用
  var importDoc = document.currentScript.ownerDocument;

  // mainDoc 是主文档(包含导入的页面)的引用
  var mainDoc = document;

  // 获取导入中的第一个样式表,复制,
  // 将它附加到主文档中。
  var styles = importDoc.querySelector('style');
  mainDoc.head.appendChild(styles.cloneNode(true));
</script>

当然如果你的导入文档中已经用 customElements.define 定义了一些组件,那么你无需任何操作就能在页面中使用它们,因为正如上面说的,导入中的脚本会在当前文档中执行。还有一些关于导入的子导入及管理依赖等等其他方面的知识这里就不展开篇幅了。

总结

可以看到上面比较详细而又不详尽的介绍了 Web Component 的相关知识,鉴于 Web Component 的兼容性及流行度,可能大家目前并不会实际使用在项目里(虽然有一些 polyfill, 比如 Polymer),只是想稍作了解,掌握 Web 的开发方向,所以这篇文章就是帮助大家快速理解其中的核心概念。我这里仅仅是抛砖引玉,如果大家有兴趣继续深入研究里面的每个 API 的话可以再去 MDN 上看看文档和相关的规范。

如何安全的存储用户密码

在我走上编程这条路之前,有个问题困扰了我很久: 为什么每次忘记某个账号的登录密码并且点击“忘记密码”试图找回原密码的时候,都永远只会让我重新填写一个新密码,而不是告诉我原来的密码是什么?很多时候我只想知道之前的密码,并不想再想出一个新的密码,为什么就是不告诉我?

直到正式开始学习编程一段时间后,突然想起这个问题才发现,原来这个问题我还是没搞明白。为了解决当初跟我有同样困惑的同学,今天我们就来仔细讨论一下这个问题。

直接存储明文密码

假如某个用户在你的网站上注册了一个账号 [email protected],密码为 abc654321

可能有些同学会觉得存储密码最直接的方式可以采用类似这样的表结构 :

username password
[email protected] abc654321

这种方法简单直接,而且完全可以满足用户想找回原密码的需求。但是,如果某个黑客获得了你数据库的访问权限,那么他就能知道每个用户对应的密码明文。很多用户在多个网站上注册的账号用的都是同样的邮箱和密码,这样相当于黑客轻易就知道了这个用户多个网站的的信息。所以基本上没有谁会采用这种方式存储密码。

存储密码的哈希值

哈希算法是个好东西,由于它不可逆的特性,我们可以存储用户密码的哈希值,类似这样:

username password
[email protected] 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8

登录验证的时候比较密码的哈希值: return sha1(password) == user[sha1_password] (更好的建议是使用 sha256)。

可以看到这个方法比直接存储明文密码要更安全,黑客无法直接知道原始密码是什么,因为无法通过哈希值反向计算出原始密码。但是,想要知道原始密码并不需要可逆的运算,黑客可以不断的计算某个值的哈希值并且与目的哈希值比较,如果相同,那么表示这个值就是原始密码。

可能你会觉得这么多密码怎么可能计算得过来,但是要知道很多人的密码都是很简短的一些有规律的数字和英文单词组成的,而且很多哈希函数比如 sha1 计算速度相当快,加之 cpu 的速度越来越快,现在普通的电脑可以一秒完成上亿次计算。(前几年可能还有人用彩虹表这种攻击方式,就是事先计算好一大堆数据的散列,然后循环比较,但是现在的 cpu 计算速度已经比过去翻了很多番,已经很少有人用这种方式了)。

而且需要注意的一点是,这个方法是同时在破解你存储的所有用户密码,所以当你数据库中存储的用户越多,黑客就越容易匹配到冲突的哈希值。

2012 年的时候 LinkedIn 就被泄露了一大批用户密码的 sha1 值,其中大多数密码都可以被轻易破解。

所以只存储用户密码的简单哈希值还是不够安全。

存储密码的加盐(固定)哈希

存储的密码值类似这样:

username password
[email protected] sha1("salt123456789" + password_1)
[email protected] sha1("salt123456789" + password_2)

注意上面的 sha1("salt123456789" + password_1) 是表示算法,表中 password 实际存储的是算法的值。

由于在计算密码哈希值的时候使用的是加盐算法(这个“盐”是固定的,很长的一段无序字符串), 所以黑客已经没办法使用彩虹表进行攻击了。

但是假如黑客攻破了你的数据库,那么很可能他也能知道你加的盐是什么(永远不要相信你存储的数据是安全的),所以这时候其实跟普通哈希无异。而且就算黑客无法知道盐是什么,他还是能用破解普通哈希值一样的方法来破解加盐哈希,只不过要多猜一个“盐”值。

可以看出固定加盐哈希还是不够安全。

存储密码的随机加盐哈希

由固定加盐哈希衍生出来的一种更为安全的办法就是,为每一个密码都分配一个单独的盐,所以存储的结构类似这样:

username salt password
[email protected] 2dc7fcc... sha1("2dc7fcc..." + password_1)
[email protected] afadb2f... sha1("afadb2f..." + password_2)

随机加盐哈希看起来跟固定加盐哈希差别不大,算法本质上是一样的,只不过是所有的密码不再共享一个固定的盐,而是每个密码都有一个对应的随机盐。这样的好处是黑客破解密码哈希值的时候没办法同时破解所有用户的密码,因为每个密码对应的盐都不一样,每个密码的破解过程都是独立的。也就是说就算你的库里存了很多用户密码,黑客也不可能通过某个盐循环计算命中多个用户(回顾上面说的普通加盐哈希的破解过程)。

但是归根结底这还是计算量的问题,现在的 cpu 计算速度已经相当快了,而且利用集群和云计算的能力,破解密码会越来越容易。

bcrypt 算法

普通哈希函数最初的设计目的并不是用来存储密码的,要真正安全地存储密码还是要用到专为这件事设计的哈希算法。这些哈希函数不仅具有唯一、哈希单向不可逆等特性,它们还被设计的“很慢”。

这些函数中一个典型的例子就是 bcrypt 函数,这个函数计算的伪代码类似这样:

hash = bcrypt(plain_password, gensalt(log_rounds))

log_rounds 参数: 工作因子(work factor),表示的是这个算法的计算量或者快慢程度。在 log_rounds 等于 12 的情况下,这个算法计算一个密码的哈希值可能需要大概 100ms,可能你会觉得很快,但是要知道这是计算一次 sha1 耗时的 10000 倍。也就是说假如黑客破解一个 sha1 算法生成的哈希值需要 1 分钟,那么破解 bcrypt 算法生成的哈希值就大概需要 7 天。

简单来说 bcrypt 算法就是重复计算内部的加密(散列)函数很多次(类似的算法还有PBKDF2),所以减慢了整体运算速度。如果你想仔细了解一个 bcrypt 的算法,可以点这里

注意上面的 log_rounds 这个参数是可配置的,而且值每增加 1 表示的是多 10 倍的计算量(对数式的)。所以如果你觉得 100ms 还是太快了,可以把 log_rounds 设置为 13,这样一次计算大概耗时 +1s, 哦不是加,就是 1s。突然想念两句诗...

由于这种算法很慢,确实非常慢,所以似乎被淘汰的彩虹表又可以利用起来。但是 bcrypt 算法是内置就支持“每个密码一个不同的盐”,所以彩虹表是没有用的。说到底,破解密码只是时间的问题,如果时间很长,那么就变成了不可能。

Mozilla 的密码存储

Mozilla 在这篇文章里介绍了他们使用的密码存储算法: password -> bcrypt(HMAC(password, local_salt), gensalt(log_rounds)),这其实是利用了两个算法的组合。

跟直接使用 bcrypt 的区别在于,这个算法首先还对密码进行了一次 HMAC 计算。Mozilla 解释这样的好处是: 用于计算 HMAC 算法的 local_salt 是直接存储在服务器而不是数据库中的,所以如果黑客攻破了数据库,他还需要再攻破服务器取得 local_salt 的值才行。

虽然听起来更可靠,但我还是觉得跟直接计算 bcrypt 比起来,HMAC 这一步有点多余。

结语

上面说了这么多,总结起来就是: 如果你想要安全的存储用户的密码,非常重要的一点是选择一个“慢”的密码存储算法,比如 bcrypt。 但是最重要的一点是,最好提示你的用户能够选择复杂一点的密码,类似 16 位以上的无序字母和数字。假如某个密码是 123456 或者 654321,那么没什么算法能够保证它是安全的,因为黑客总是从最简单最常用的一些密码列表开始匹配。

i-change-all-my-passwords-to-incorrect-so-whenever-i-forget-it-says-your-password-is-incorrect

理解 SSH 加密和连接过程

并不逐字翻译,原文请点这里

介绍

SSH (安全 shell),是一个安全协议,同时也是安全地管理远程服务器时用的最多的方法。它提供了一种能够在客户端和服务器端建立安全传输和验证,以及执行远程命令的方法,而这里面使用了多种加密技术。

这篇文章我们会学习 SSH 使用的底层的加密技术以及其用于建立安全连接的具体方法,这些知识会对你理解不同层的加密和双方建立连接到验证所需的不同步骤有所帮助。

对称加密、非对称加密和哈希 (Hashes,散列)

为了确保信息的安全传输,SSH 在事物中的多个地方采用了多种不同类型的数据操纵技术,包括对称加密,非对称加密以及哈希。

对称加密

加密和解密数据所用的组件之间的关系可以确定当前加密方法是对成加密还是非对成加密,简单来说就是,如果一个密钥能够同时加密和解密数据,那么它就是对称加密。这也意味着任何持有这个密钥的人都能解密由这个密钥加密的数据。

对称加密通常被称为"共享密钥"或者"私有密钥"加密,典型的情况就是一个密钥负责所有的操作,或者一对密钥,但是这一对密钥的关系很简单,通常能根据其中一个轻易地推出另一个。

对成加密被 SSH 用来加密整个连接。和很多人想的不一样,非对称加密中的公/私钥匙对只是用来验证,而不是用来加密连接的。对称加密甚至能够防止密码认证阶段的数据被偷窥。

建立这个密钥需要客户端和服务器端同时工作,并且生成的密钥是不能让外部知道的。密钥通过称为密钥交换算法的过程创建,此算法可以让客户端和服务器端各自利用一些私有的数据和一些共享的公开的数据就能计算出一样的密钥,这个过程后面会详细说明。

由此过程创建的对称加密密钥是基于会话 (session-based) 的,并且服务器端和客户端实际通信就是用的这个密钥加密,所以一旦建立连接,剩下的所有数据传输都必须用这个共享密钥加密。这一步是在认证客户端之前完成的。

SSH 可以配置使用多种不同的对称加密算法,包括 AES, Blowfish, 3DES, CAST128, 和 Arcfour,双方会协商选择一种都支持的算法。

以 Ubuntu 14.04 为例,客户端和服务器默认的算法列表可能是: aes128-ctr, aes192-ctr, aes256-ctr, arcfour256, arcfour128, [email protected], [email protected], [email protected], aes128-cbc, blowfish-cbc, cast128-cbc, aes192-cbc, aes256-cbc, arcfour。所以假设两台 Ubuntu 服务器相互连接(在没有更改默认算法列表的情况下),会使用 aes128-ctr 作为它们的加密算法。

非对称加密

非对称加密不同于对称加密,它需要两个密钥,其中一个称为私钥,另一个称为公钥

公钥可以任意共享,它与其配对的私钥相关联,但是私钥却不能从公钥推导出来。两者之间的数学关系确保了用公钥加密的数据只能由匹配的私钥才能解密出来。这是单向的,意味着公钥不能解密自己加密的数据,也不能解密私钥加密的数据。

译者注:其实并不是不能解密私钥加密的数据,作者这里的意思是不应该用私钥加密传输信息的方式来保证数据安全。因为算法上公钥和密钥是相等的,公钥加密的信息私钥可以解密,私钥加密的信息公钥也可以解密,但是公钥加密的信息公钥自己不能解密,所以考虑到公钥是共享的,不应该使用私钥来加密传输信息,而应该用来签名,这也是为什么非对称加密技术只能用来验证而不是加密整个连接。

私钥应该是完全保密的,这是公钥模式工作的关键。私钥是唯一能解密由公钥加密的数据的东西,基于这个事实,任何人能够解密加密过的数据就代表他拥有了私钥。

SSH 在几个不同的地方使用了非对称加密,在用于建立对称加密 (用于加密会话) 的初始密钥交换过程中就使用了非对称加密。这个阶段双方都生成了钥匙对,并且交换了公钥以产生将用于对称加密的共享密钥。

另一个更好的说明 SSH 怎么利用非对称加密的地方是 SSH 基于密钥的身份验证。SSH 密钥对可以用于向服务器端验证客户端,客户端首先会生成密钥对,并且把公钥上传到远程服务器,存放在 ~/.ssh 目录下的 authorized_keys 文件中。

在建立了保护通讯的对称加密之后,客户端还必须认证以允许访问服务器。服务器可以使用上面所说文件中保存的公钥来加密一个质询消息发送到客户端,如果客户端能够解密这个消息,那就证明确实拥有相匹配的私钥,这个时候服务器应该允许此客户端访问。

哈希 (Hashes)

SSH 还利用了另一种数据结构: 加密哈希。加密哈希函数是一种创建简明"签名"和对一组信息生成摘要的方法,这种方法生成的结果唯一且不可预测,也不能根据哈希结果逆向推出原始信息。

同样的数据会产生同样的哈希值,修改原始数据的任何部分都会产生完全不同的哈希值。用户虽然不能根据哈希值推出原始的信息,但是能知道给定的数据是否产生了给定的哈希值。

基于这些特性,哈希主要用来验证数据的完整性和通讯的真实性。哈希在 SSH 中的主要应用是 HMAC ,或者说是基于哈希的消息认证码,用来确保接收到的消息文本是完整未修改过的。

作为上面说的对称加密协商的一部分,双方会选择一种消息认证码 (MAC) 算法,选择的过程就是客户端会列出一系列算法,服务器端会选择自身支持的第一项。

当协商加密完成后的所有消息发送时都要包含 MAC 数据,这样接收方才能验证消息的完整性。MAC 是由对称加密中的共享密钥计算而来的,发送出去的数据包包含一系列的信息以及实际消息本身。

MAC 自身会放到对称加密信息之外,也就是利用共享密钥加密的实际数据之外,作为数据包的最后部分。安全研究人员通常推荐都是先加密数据,再计算 MAC 数据。

SSH 是如何工作的?

你可能已经基本对 SSH 的工作机制有所了解了,SSH 协议采用客户端-服务器模型来验证彼此并加密它们之间传输的数据。

服务器组件会侦听指定的端口,并负责协商安全的连接、认证连接方,并且会衍生 (spawn) 一个正确的环境给已经认证的客户端。

而客户端负责发起与服务器的 TCP 握手、协商安全连接、验证服务器的身份与之前记录的信息匹配,并且提供认证的凭证。

SSH 会话在两个独立的阶段建立,第一个阶段是协商建立加密以保护未来通讯的过程,第二个阶段就是认证阶段。

协商会话加密

当客户端发起 TCP 连接时,服务器以其支持的协议版本作为响应,如果客户端能匹配其中的某个协议,则连接继续进行。服务器需要提供自身的主机公钥,客户端就可以使用这个密钥来检查这是否是预期的主机。

这个时候双方会使用被称为 Diffie-Hellman 的算法来协商一个会话密钥,这个算法可以让客户端和服务器各自通过组合一些私有数据和彼此交换的公有数据来得到相同的私有会话密钥 (secret session key)。

会话密钥将会用来加密整个会话,用与这个过程的公钥私钥对与用于向服务器认证客户端的 SSH 钥匙对完全不同。

使用了经典的 Diffie-Hellman 算法的基本过程大概是这样:

  1. 首先双方同意使用同一个大质数,它将作为种子值 (seed value)。
  2. 双方确认同一种加密生成器 (通常为 AES),其将用于以预定义的方式处理值。
  3. 双方再各自提出一个对彼此保密的质数,这个数就是生成会话密钥阶段的私钥 (与认证阶段的 SSH 私钥是完全不同的)。
  4. 上一步中的私钥,与加密生成器和第一步中的大质数可以计算出一个公钥,这个公钥是可以共享的。
  5. 双方交换第 4 步中的公钥。
  6. 这个时候双方使用第 3 步中各自的私钥、对方的公钥以及原始的那个大质数计算出共享密钥,虽然这是由每一方独立计算的,但是算法会保证双方得到相同的密钥值。
  7. 此后的会话将使用上一步计算得到的共享密钥加密。

用于加密接下来通讯的共享密钥加密称为二进制分组协议。上面说的过程允许每一方同等的参与生成共享密钥,而不是由某一方享有控制权。要注意的是,共享密钥并不是通过不安全的信道传输的,而是通过上面说的算法各自算出来的。

生成的密钥是对称密钥,意味着它可以解密由自己加密的信息,这样的话接下来的所有通讯外界是无法破解的。

建立了会话加密之后,接下来就是用户认证阶段。

认证用户

这一阶段涉及用户认证和服务器决定是否允许客户端访问。基于服务器能够接受的程度,有多种不同的方法用于认证。

最简单的可能就是密码认证: 服务器简单地提示客户端需要输入相应的密码。密码通过已经协商好的密钥加密,因此它是安全的。

虽然密码是被加密过的,但是由于对密码复杂性的限制,通常并不推荐这种方法。与其他认证方法相比,自动脚本可以轻松破解正常长度的密码。(译者注: 这种方法很容易被中间人攻击 (Man-in-the-middle attack),推荐使用下面的方法)

最流行和被推荐的最多的方法是使用 SSH 密钥对。SSH 密钥对是非对成密钥对,意味着这两个相关的密钥提供不同的功能。

公钥加密的数据只能被私钥解密,而且公钥可以自由的共享,因为不能从公钥导出私钥。

使用 SSH 密钥对认证的过程在对称加密之后开始,大致过程可能就是:

  1. 客户端首先向服务器发送用于认证的密钥对的 ID。
  2. 服务器根据 ID 检查客户端指定登录账户目录下的 authorized_keys 文件。
  3. 如果服务器在这个文件里找到了指定 ID 对应的公钥,那么它会用这个公钥加密一串随机数。
  4. 将加密的随机数发回客户端。
  5. 如果客户端确实有对应的密钥,那么它就能够解密出原始的那串随机数。
  6. 解出随机数之后,组合第一阶段得到的会话密钥生成一个新值,并且计算这个新值的 MD5 值。
  7. 客户端发送这个 MD5 值给服务器,作为第 4 步对服务器的响应。
  8. 服务器使用自己的会话秘钥和发送给客户端的随机数计算一个 MD5 值,如果这个值和客户端返回的 MD5 值相等,那么表示此客户端确实拥有对应的私钥,验证通过。

非对称加密允许服务器使用公钥加密数据发送给客户端,客户端只要能解密数据就能证明它用于对应的私钥。SSH 的整个模型都很好的利用了两种不同类型加密 (对称加密和非对称加密) 的特定优势。

结语

了解 SSH 中通讯协商和各个层中的加密能够更好的帮助你理解当我们登录远程服务器的时候都发生了什么。希望你现在对各个组件和算法之间的关系,以及这些部分是如何组织在一起的有一个更好的了解。

关于 Node.js 正则表达式拒绝服务攻击

从 npm audit 说起

今天尝试用 npm audit 在项目里跑了一下,发现了很多类似下面的错误:

image

如果你还不清楚 npm audit 是做什么的,可以参考官方文档。简单来说 npm audit 会对项目的依赖包进行安全扫描,并且列出存在的可能遭受攻击的安全隐患,比如上面截图中的信息就是一个潜在的被攻击点。

Regular Expression Denial of Service (ReDoS)

图片第一行显示的 Regular Expression Denial of Service 成功引起了我的注意。Denial of Service 我们都知道,拒绝服务攻击,以一种简单暴力的方式消耗被攻击者的资源,让其服务到达不可用的地步,比如常见的 DDoS。那么 Regular Expression Denial of Service (以下简称 ReDoS) 通过字面意思理解应该就是:正则表达式拒绝服务攻击。

正则表达式的匹配通常是比较耗费计算性能的,尤其是当正则表达式中包含复杂的子表达式的重复匹配、或者重复匹配的简单子表达式也可能匹配另一个子表达式的时候。当一个正则表达式存在这些问题的时候,一些用户的输入可能会导致匹配运行效率非常低下,从而引发 DoS (因为计算资源可能被耗尽)。

Node.js 中的 ReDoS

说了这么多,也许不如一个例子能够把我的意思表达的更清楚:

image

测试环境:MacBook Pro (Retina, 15-inch, Mid 2015), 2.2 GHz Intel Core i7

上面正则表达式看起来非常简单,但是存在我们上面说的可能导致匹配效率低下的问题。可以看到这个简单的匹配耗时大约 5 秒钟,对于 Node.js 这种基于 event loop 的单进程服务器来说是不可接受的。

解决办法

尽量使用社区开源的正则表达式。如果你正在做一些常规的字符串校验,比如邮件、电话号码的匹配,那么可以尝试 validator.js

如果需要自己编写正则表达式,可以使用 safe-regex 这样的工具来校验自己的正则表达式是否有潜在的问题,也可以使用 regex101 在线进行检验 (regex101 功能十分强大,界面也很清新,另外还提供了 debugger 功能),比如上面的这个例子在 regex101 里会被检测到有 “灾难性的回溯”(Catastrophic backtracking) 问题:

image

附录

出于好奇,用 Rust 写了跟上面 Node.js 例子中的正则表达式,下面是执行的结果:

image

Rust 好像对于正则表达式这些潜在的性能问题有一些预防的解决方案 (只调用回溯引擎的混合解决方案),具体我也不是太了解,有兴趣的小伙伴可以自行查资料。不过 Rust 确实是非常快的,真的要抽空好好学习 Rust 咯,后面用得到的地方还是很多的。

Immutable.js 简介

一篇简单的介绍 Immutable.js 的文章,原文在这

许多开发者在处理函数式编程的时候强调数据的不可变性。函数式代码是可测试的,这是因为函数在处理数据的时候把其当成不可变的。但是在实际操作中我经常看到这个原则被打破。下面我会展示一种能从你代码中完全消除这种副作用的方法: 使用 immutable.js

救星 Immutable.js

通过 npm 安装或者直接引入源文件 immutable.min.js 就可以直接使用它。

我们的第一个例子来探索一下 immutable 的 map 数据类型。map 基本上就是一个包含键值对的对象。

var person = Immutable.Map({ 
    name: 'John', 
    birth: 594687600000,
    phone: '12345678'
});

var changePhone = function( person, newPhone ) {
    return person.set( 'phone', newPhone );
};

var person2 = changePhone( person, '87654321' );

console.log( person2 == person, person2 === person );
// false false

console.log( person.get('phone'), person2.get( 'phone' ) );
// 12345678 87654321

console.log( person.phone, person2.phone );
// underfined undefined

首先,person 拥有 name, birthphone 属性。changePhone 函数返回一个新的 immutable map。当 changePhone 调用的时候,返回值赋给了 person2 变量,这时 person2person 完全不相等。每个 map 的 phone 属性可以通过 get 方法获得。因为 map 的属性被包装在 get/set 接口中,所以不能直接获取或者修改 map 的属性值。

var person3 = changePhone( person, '12345678' );

console.log( person3 == person, person3 === person );
// true true

var person4 = changePhone( person, '87654321' );
var person5 = changePhone( person4, '12345678' );

console.log( person5 == person, person5 === person );
// false false

immutable.js 相当的智能,它能够检测一个属性是否是被设置为了跟之前一样的值 (译者注: 以 map 为例,也就是虽然调用了 set 方法,但是新的值与老值一样)。在这种情况下, ===== 都会返回 true,因为 o.set 返回的就是 o 本身。其他所有情况下,当真正的改变发生,会返回一个全新的对象引用。这就是为什么尽管 person5person 拥有完全相同的键值但是他们还是不相等。这里需要提醒你一下,在许多真实场景中,当属性值产生变动后,person 应该被丢弃,所以 personperson5 之间的比较通常没什么用。

如果我们想比较一下 personperson5 之间属性和属性值是否相等,那么可以用 map 的 equals 方法:

console.log( person5.equals( person ) );
// true

不可变数据结构虽然很棒,但是我们不是任何时候都需要它们。比如我们通常会发送 JSON 数据到服务器,而不是 immutbale.js 的数据结构。因此有需要把 immutbale.js 数据结构转换成 JavaScript 对象或者 JSON 字符串。

person5.toObject()
// Object {name: "John", birth: 594687600000, phone: "12345678"}

person5.toJSON()
Object {name: "John", birth: 594687600000, phone: "12345678"}

JSON.stringify( person5 )
// '{"name":"John","birth":594687600000,"phone":"12345678"}'

toObjecttoJSON 方法都会返回代表这个 map 数据结构的一个 JavaScript 对象。因为 toJSON 方法返回一个 JavaScript 对象,所以 immutable.js 数据结构能够直接调用 JSON.stringify 方法返回 JSON 字符串(译者注: 如果被序列化的对象有 toJSON 方法,那么 JSON.stringify 会序列化这个方法的返回值)。

如果正确使用了不可变数据结构的话,那么我们程序的可维护性自然就会得到改善。使用不可变数据结构会让你的代码没有副作用。

Immutable.js 数据结构

Immutable.js 有以下数据结构:

  • List
  • Stack
  • Map
  • OrderedMap,
  • Set
  • OrderedSet
  • Record
  • lazy Seq

下面我们来简单的看一下这些数据结构。

List: List 对应于 JavaScript 中的数组。基本上所有常用的数组操作方法 List 都有,但不同的是只要改变了原始对象的内容,都会返回一个全新的 immutable 对象。

var qwerty = Immutable.List(['q','w','e','r','t','y']);

var qwerty.size
// 6

var qwertyu = qwerty.push( 'u' );
// Object {size: 7, _origin: 0, _capacity: 7, _level: 5, _root: null…}

var qwert = qwertyu.pop().pop();
// Object {size: 5, _origin: 0, _capacity: 5, _level: 5, _root: null…}

var wertArray = qwert.shift().toJSON();
// ["w", "e", "r", "t"]

var qwertyuiArray = qwert.concat( 'y', 'u', 'i' ).toJS();
// ["q", "w", "e", "r", "t", "y", "u", "i"]

Stack: 先进后出数据结构,也就是栈。对应的也是 Javascript 数组,意味着 index0 的元素将会首先被 poppedstack 中的所有元素都可以通过 get 方法获得,而不一定非得 popping 出来,但是只能通过 pushpop 方法才能修改 stack。

var twoStoreyStack = filo.push( '2nd floor', '1st floor', 'ground floor' );

twoStoreyStack.size
// 3
twoStoreyStack.get()
// "2nd floor"
twoStoreyStack.get(1)
// "1st floor"
twoStoreyStack.get(2)
// "ground floor"

var oneStoreyStack = twoStoreyStack.pop();
var oneStoreyJSON = JSON.Stringify( oneStoreyStack );
// '["1st floor","ground floor"]'

Map: 其实我们在上面的代码中已经了解过 Map 数据结构了,它对应的就是 JavaScript 对象。

OrderedMap: 可排序 map 就是混合了对象和数组的特点。你可以把它当成键根据它们被添加的顺序而被排序过的对象。修改已经存在的属性值不会改变键的顺序。

键的顺序可以通过 sortsortBy 方法被重新定义,但是注意这会返回一个全新的不可变可排序 map。

需要注意一个比较危险的地方就是可排序 map 的序列化表单值是一个简单的对象。考虑到一些语言如 PHP 把自己语言的对象当成可排序 map,理论上通过可排序 map 可以相互交互。但是为了保持清晰度,在实践中我并不推荐这种方式来进行交互。

var basket = Immutable.OrderedMap()
                      .set( 'Captain Immutable 1', 495 )
                      .set( 'The Immutable Bat Rises 1', 995 );

console.log( basket.first(), basket.last() );
// 495 995

JSON.stringify( basket );
// '{"Captain Immutable 1":495,"The Immutable Bat Rises 1":995}'

var basket2 = basket.set( 'Captain Immutable 1', 695 );

JSON.stringify( basket2 );
// '{"Captain Immutable 1":695,"The Immutable Bat Rises 1":995}'

var basket3 = basket2.sortBy( function( value, key ) { 
    return -value; 
} );

JSON.stringify( basket3 );
// '{"The Immutable Bat Rises 1":995,"Captain Immutable 1":695}'

Set: Set 就是值唯一的数组,且所有常用的数组操作方法可以用。理论上,set 中元素的顺序是无关紧要的。

var s1 = Immutable.Set( [2, 1] );
var s2 = Immutable.Set( [2, 3, 3] );
var s3 = Immutable.Set( [1, 1, 1] );

console.log( s1.count(), s2.size, s3.count() );
// 2 2 1

console.log( s1.toJS(), s2.toArray(), s3.toJSON() );
// [2, 1] [2, 3] [1]

var s1S2IntersectArray = s1.intersect( s2 ).toJSON();
// [2]

OrderedSet: 顾名思义,OrderedSet 就是根据被添加顺序排序的 Set。当你需要考虑元素的顺序的时候应该使用 OrderedSet

var s1 = Immutable.OrderedSet( [2, 1] );
var s2 = Immutable.OrderedSet( [2, 3, 3] );
var s3 = Immutable.OrderedSet( [1, 1, 1] );

var s1S2S3UnionArray = s1.union( s2, s3 ).toJSON();
// [2, 1, 3]

var s3S2S1UnionArray = s3.union( s2, s1 ).toJSON();
// [1, 2, 3]

Record: record 类似于 JavaScript 中的类,这个类拥有一些默认的键值。当实例化一个 record 的时候,定义在 record 中的键的值能够被赋值,而对于没有提供值的键则会使用 record 中的默认值。

var Canvas = Immutable.Record( { width: 1024, height: 768 } );

console.log( 'constructor ' + typeof Canvas );
// constructor function

var myCanvas = new Canvas();

myCanvas.toJSON()
// Object {width: 1024, height: 768}

myCanvas.width
// 1024

var myResizedCanvas = new Canvas( {width: 400, height: 300} );

myResizedCanvas.width
// 400

Seq: sequences 是一系列有限或者无限惰性求值的数据结构。Seq 中的元素只有在需要的时候才会去计算求值。根据类型的不同,我们可以分为 KeyedSeqIndexedSeq 或者是 SetSeq。有限或者无限的 sequences 可以这么定义:

  • Immutable.Range(),
  • Immutbale.Repeat(),
  • Seqs 的改变可以通过一些函数式工具如 map, filter.

有限的 Seqs 同样能通过计数( enumeration ) 来实现:

var oneToInfinitySeq = Immutable.Range( 1 );

var isEven = function( num ) { return num % 2 === 0; }
var evenPositiveSeq = oneToInfinitySeq.filter( isEven );

var firstTenPositivesSeq = evenPositiveSeq.take(10);
firstTenPositivesSeq.toJSON();
// [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

var firstTenElements = Immutable.Repeat( /* undefined */ )
                                .map( Math.random )
                                .take( 10 )
                                .toJSON();
// 生成 10 个随机数的一个数组

惰性求值的一个好处就是可以定义无限的序列,另一个好处就是性能优化。试着确定下面的代码会输出什么样的信息:

var toUpper = function( item ) {
    var upperItem = item.toUpperCase();
    console.log( item + ' has been converted to ' + upperItem );
    return upperItem;
}

var seasons = Immutable.Seq( ['spring', 'summer', 'fall', 'winter'] )
                       .map( toUpper );

console.log( 'Item at index 1: ', seasons.get( 1 ) );
console.log( 'Item at index 0: ', seasons.get( 0 ) );
console.log( 'Seasons in an array: ', seasons.toJS() );

结果可能会出人意料。考虑到求值是惰性的,并且我们处理的是一个有限的数据结构,seasons 中的元素可以直接被获取到。因此当你获取大写版本的元素时会按需求值。当 seasonstoJSON 方法被调用的时候,所有元素会被一起计算。默认的,惰性链是不会缓存计算结果的。下面是输出结果:

summer has been converted to SUMMER
Item at index 1:  SUMMER
spring has been converted to SPRING
Item at index 0:  SPRING
spring has been converted to SPRING
summer has been converted to SUMMER
fall has been converted to FALL
winter has been converted to WINTER
Seasons in an array:  ["SPRING", "SUMMER", "FALL", "WINTER"]

上面的实验对于无限序列同样适用。元素会按需求值,且没有缓存计算结果。

Summary

Immutable.js 是一个能提供不可变数据结构相当不错的库。它填充了 underscore.js 的一些缺憾: 对于不同数据结构的操作强制在 JavaScript 的数组和对象上、混合了数据类型的概念以及数据没有不可变性。尽管 lodash.js 尝试纠正其中的一些缺点,但是为了与 underscore.js 兼容还是导致了其不直观的结构。lazy.js 有懒加载功能,但是它更多的是被当成一个惰性的 underscore 版本。

immutable.js 的名字就很好的说明了在编写纯函数式代码的时候我们应该把处理不可变数据结构当成一个必要的条件。记住,使用正确的数据结构能够提高你代码的可维护性。

Object.observe为何要被移除?

前些天看有人讨论说 Object.observe (后面简称 O.o ) 将会从当前提案中移除,甚是震惊,为何大家一致叫好的特性会被废弃?要知道 O.o 之前已经到了 es7 的 stage-2,突然要被移除有点不解。经过了一番搜索,在一个帖子上找到了下面这段信息 (不逐字翻译,加入个人理解):

三年前,Rafael Weinstein, Erik Arvidsson和我着手设计并实现我们认为是 MDV ("model-driven views")本质上要有的数据绑定系统。最初我们在 V8 的分支上实现了一个原型,然后获得了 V8 团队的同意从而打造了一个真实的上线版本( a real version upstream ),同时也在努力把 O.o 推为 ES7 的标准,以及和 Polymer 团队合作把他们的数据绑定 ( data-binding ) 系统构建在 O.o 之上。

三年后,风云际变。一些其他的数据绑定框架 (比如 Ember 和 Angular)也表示过对 O.o 的兴趣,但是很难看到它们能再改进现有的模型并整合 O.o。Polymer 1.0 完全重写了之前的代码,不过这次重写的版本却并没有使用 O.o。React 处理数据模型的方式更是尽量避免数据状态可变的特性,并且这种方式在 web 开发里变得相当流行了。

经过与组织的多次讨论之后(译者注:这里好像把原作者翻译成党员了 - -),我计划从 TC39 (译者注:TC39 是ECMAScript 标准委员会,负责 ECMAScript 的规范制定) 撤销对 Object.observe (目前正在stage 2) 的提案,并希望年末能从 V8 中移除对其的支持 (根据 chromestatus.com 的数据显示,这个特性在页面上的使用率为0.0169%)。

对于出于实验性质使用了 O.o 并且因为被坑而在寻找一个替代品 (transition path) 的开发者们,可以考虑使用polyfill,比如 MaxArt2501/object-observe ,或者使用一个包装过的库,如 polymer/observe-js

另外也有一篇文章解释了 O.o 要被移除提案的原因,里面就引用了这段话的部分内容。文章还说到Angular 2团队曾经实验性的使用了 O.o,但是因为性能原因最终放弃了。原因在于 O.o 的使用限制了很多 V8 中已有的优化,导致被 observed 的对象会比 non-observed 的对象慢得多。过多的上下文切换 (框架和浏览器之间) 会对异步的数据变化通知造成挑战,也很难对框架进行大幅性能优化 (macro-optimizations)。Polymer 团队的头也说用了 O.o 调试很诡异。

总之基于种种原因,原生的 O.o 算是没戏了,已经在项目里用了或者想用这个特性的小伙伴可以找一些库支持,但是正如 O.o 作者说的,immutable.js 会是一个非常不错的选择!:stuck_out_tongue:

2016/4/26 更新: 测试在 Chrome 50 下这个特性已经被移除了。

ES6 模块加载: 比你想象的更复杂

Nicholas C. Zakas 的一篇博客,原文点这里

ECMAScript 6 中最让人期待已久的特性之一就是模块化正式成为语言的一部分。多年来,JavaScript 开发者一直在如何组织他们的代码上挣扎着,以及如何选择 RequireJS, AMD 或者 CommonJS 来开发。模块化的正式定稿将会在未来完全消除这个问题,但现在,还存在很多关于模块化是如何工作的疑问。这些疑问存在的部分原因是目前还没有引擎能原生加载 ES6 模块,所以我希望这篇文章能澄清一些疑问。

什么是模块 (module)?

首先需要明白规范定义了两种 JavaScript 程序存在的形式: script 标签 (自 JavaScript 诞生开始我们就一直在用的) 和 ES6 的模块 ( module)。script 标签我们再熟悉不过,但是处理 module 的方式却有些不同,下面列出了模块的特殊点:

  1. 总是处于严格模式
  2. 具有顶层 (top-level) 作用域,但又不是全局 (global) 作用域
  3. 可以通过 import 关键字从其他模块导入程序绑定 (bindings, 通常就是变量或者函数)
  4. 可以通过 export 关键字导出指定的 bingdings

这些不同点看起来很微妙,但实际上足够让模块在解析和加载上与现有的 script 完全不同。

解析的差异

我们在 ESLint 上收到的关于 ES6 模块最常见的一个问题就是:

为什么我需要在一个模块被解析前指定它是一个模块?为什么不能通过查找 import 或者 export 关键字来确定它是模块?

我经常能在网上见到这种问题,因为大家一直难以理解为什么 JavaScript 引擎和工具不能自动检测一个文件是模块而不是普通的通过 script 标签引入的脚本。第一眼看过去,似乎检测 importexport 关键字就足以判断一个 JavaScript 文件是不是模块了,但实际上,只能说这样太 naive 了。

尝试去猜测用户的意图这太危险也不够清晰,如果你猜对了,也许能搞个大新闻,但是如果猜错了,你将来就要负责任的。

解析的挑战

为了能自动检测某个 JS 文件是否是模块,首先得解析整个文件。模块并不一定会使用 import 关键字,所以想的乐观一点的话就是模块很可能在文件的末尾使用了 export 语句,因此你也无法逃避要去解析整个文件。

要注意模块始终是处于严格模式下的,而严格模式不仅对于运行时有要求,还定义了以下的一些语法限制 (不能出现在严格模式中):

  1. with 语句
  2. 函数重复命名的参数
  3. 八进制数字直接量 (比如 010)
  4. 重复属性名 ( ES5 会报错,ES6 不会)
  5. 使用 implements, interface, let, package, private, protected, public, staticyield 作为标识符

所有上面这些语法错误在非严格模式中都不算错误。如果你已经知道了某个文件的末尾有 export 关键字,那么实际上你就得在严格模式下重新解析一遍整个文件,以确保不会出现上面的语法错误,而第一次解析就浪费在非严格模式的解析中了 (为了获取 export 关键字)。

很明显,如果你想通过文件内容来检测它是否是模块,那么你就必需总是强制地先把它当成模块来解析。由于模块的语法限制是严格模式外加允许存在 exportimport 关键字,所以你得默认允许这两个关键字的出现。如果你在非严格模式下去解析,那么 exportimport 关键字会被解析为语法错误。当然你可以自定义一个非严格模式下允许存在这两个关键字的解析模式,不过这样的话在这种反常模式解析出来的代码也不能用,因此当模式确定之后需要第二次解析。

那什么时候才能确定是模块?

边缘的情况就是模块其实并不一定会使用 exportimport 关键字,一个模块可以既不导入也不导出任何东西,很可能就是在全局作用域中修改一些什么东西。举个栗子,你可能只是想当 window.onload 在浏览器里被触发的时候输出一条信息,那么你定义的模块就会像这样:

// 一个合法的模块

window.addEventListener("load", function() {
    console.log("Window is loaded");
});

这个模块能被其他模块导入,自己也能被加载执行。如果只看源代码的话,没办法知道这其实是一个模块。

总结: exportimport 关键字的存在可能指示这是一个模块文件,但是没有的话也不能就说一定不是模块,所以在解析的时候是没有高效的办法确定一个文件是否是模块的。

加载的差异

解析模块的这些差异看起来着实有些微妙,但是对于加载模块的差异来说就不是这样了。当一个模块被加载完之后,import 关键字会触发加载指定的文件。被加载进来的文件必需在完全解析和加载 (不报错) 之后,模块才能开始执行。为了能尽快完成这些工作,当解析到 import 关键字的时候就会去加载指定的文件,然后优先解析模块其余的代码。

一旦依赖加载完毕,会有额外的一步来验证这些被导入的引用绑定实际上是真的存在于依赖文件中的。如果你从 foo.js 导入了 foo,那么 JavaScript 引擎就需要在继续执行前验证 foo 确实是从 foo.js 导出来的。

加载 (loading) 将会如何工作?

现在我希望你已经清楚了为什么在导入和解析一个模块之前就需要指明这是一个模块。在浏览器里你需要这样导入一个模块 (html) :

<script type="module" src="foo.js"></script>

script 标签没什么特别的,但是 type 要指定为module,这就告诉浏览器 foo.js 是一个模块。如果 foo.js 里又通过 import 导入了其他模块,那么这些文件会被动态的加载进来。

NodeJS 还没决定怎么加载 ES6 模块,但是目前最受推崇的一种方式是使用一个特定的扩展名,比如 .jsm,这样 NodeJS 就能知道这是一个 ES6 模块文件,从而能正确的加载它。

结论

普通脚本文件和 ES6 模块之间的差异确实很微妙,所以很难让一些开发者们理解为什么需要事先就定义一个文件是模块文件。我希望这篇文章能稍微澄清为什么不可能通过源代码就能自动检测一个文件是不是模块,以及一些工具比如 ESLint 需要你事先指定文件的类型。未来 ES6 模块将会成为最主要的 JavaScript 文件类型,而 script 标签只会存在老应用当中,到那个时候,很可能这些工具会默认的把文件都当成模块来处理。与此同时,我们正在经历一个 script 和 ES6 模块同时存在的艰难发展期。

更新

修正 (2016/4/06): 删掉了之前说的 import 语句只能出现在文章的开头 (译者注: import 其实可以出现在模块的任何位置,只要处于顶层就行,如果处于块级作用域则会报错)。

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.