Giter Site home page Giter Site logo

muyunyun / blog Goto Github PK

View Code? Open in Web Editor NEW
1.6K 73.0 232.0 257.77 MB

Life is a moment :notebook_with_decorative_cover:

Home Page: http://muyunyun.cn/blog

JavaScript 100.00%
javascript react data-structure design-pattern front-end css typescript algorithm modern-testing raspberrypi

blog's People

Contributors

muyunyun avatar roy-lau avatar zaviertang 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  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  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

blog's Issues

实现一个自定义工具类库

项目地址: diana

文档地址: http://muyunyun.cn/diana/

造轮子的意义

为啥已经有如此多的前端工具类库还要自己造轮子呢?个人认为有以下几个观点吧:

  • 定制性强,能根据自己的需求为主导延伸开发。万一一不小心还能帮到别人(比如 React 库);
  • 纸上得来终觉浅,很多流行的库,只是照着它们的 API 进行使用,其实这些库里蕴含着大量的知识、技巧,最好的办法就是仿照它们来写些小 demo,从而体会这些库的精髓;
  • 造轮子的过程中能让自己体会到与平常业务开发不一样的乐趣;比如和日常业务开发中很大的一个区别是会对测试用例具有比较严格的要求;而且写文档能力提升了。
  • 就先瞎编到这里了。。。

抛开内部方法(写相应的专题效果可能会更好,所以这里先略过),下面分享一些开发 diana 库 时的一些心得:

项目目录结构

├── LICENSE                  开源协议
├── README-zh_en.md          英文说明文档
├── README.md                中文说明文档
├── coverage                 代码覆盖率文件
├── docs                     文档目录
│   └── static-parts
│       ├── index-end.html   静态文档目录结尾文件
│       └── index-start.html 静态文档目录开头文件
├── karma.conf.js            karma 配置文件
├── lib
│   ├── diana.back.js        服务端引用入口
│   └── diana.js             浏览器引用入口
├── package.json
├── script
│   ├── build.js             构建文件
│   ├── check.js             结合 pre-commit 进行 eslint 校验
│   ├── tag-script.js        自动生成文档的标签
│   ├── web-script.js        自动生成文档
│   ├── webpack.browser.js   浏览器端 webpack 配置文件
│   └── webpack.node.js      服务器端 webpack 配置文件
├── snippets
├── src
│   ├── browser              浏览器端方法
│   ├── common               共用方法
│   ├── node                 node 端方法
│   └── util.js              库内通用方法
├── tag_database             文档标签
└── test                     测试文件
    ├── browserTest
    ├── commonTest
    ├── index.js
    └── nodeTest

目录结构也随着方法的增多在不停迭代当中,建议直接到库中查看最新的目录结构。

相应地,具体的方法会随着时间迭代,所以首先推荐查看文档,点击如下图的 Ⓢ 就能查看源码。

让模块同时在 Node.js 与浏览器中运行

我们可以通过如下方法来判断模块当前是运行在 Node.js 还是浏览器中,然后使用不同的方式实现我们的功能。

// Only Node.JS has a process variable that is of [[Class]] process
const isNode = Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'

但如果用户使用了模块打包工具,这样做会导致 Node.js 与浏览器的实现方式都会被包含在最终的输出文件中。针对这个问题,开源社区提出了在 package.json 中添加 browser 字段的提议,目前 webpack 和 rollup 都已经支持这个字段了。

给 browser 字段提供一个文件路径作为在浏览器端使用时的模块入口,但需要注意的是,打包工具会优先使用 browser 字段指定的文件路径作为模块入口,所以你的 main 字段 和 module 字段会被忽略,但是这会导致打包工具不会优化你的代码。详细信息请参考这个问题

diana 库 为了在不同环境中使用适当的文件,在 package.json 中进行了如下声明:

  "browser": "lib/diana.js",
  "main": "lib/diana.back.js", // 或者 "module": "lib/diana.back.js",

这样一来,在 node 环境中,引用的是 lib/diana.back.js 文件,在浏览器环境中,引用的是 lib/diana.js 文件。然后就能愉快地在浏览器端和 node 端愉快地使用自己特有的 api 了。

常见模块规范比较

另外为了使 diana 库 的打包文件兼容 node 端、以及浏览器端的引用,选择了 UMD 规范进行打包,那么为什么要选择 UMD 规范呢?让我们看下以下几种规范之间的异同:

CommonJS

  • CommonJs 是服务器端模块的规范,Node.js 采用了这个规范。这些规范涵盖了模块、二进制、Buffer、字符集编码、I/O流、进程环境、文件系统、套接字、单元测试、服务器网关接口、包管理等。

  • 根据 CommonJS 规范,一个单独的文件就是一个模块。加载模块使用 require 方法,该方法读取一个文件并执行,最后返回文件内部的 exports 对象。

  • CommonJS 加载模块是同步的。像 Node.js 主要用于服务器的编程,加载的模块文件一般都已经存在本地硬盘,所以加载起来比较快,不用考虑异步加载的方式,所以 CommonJS 规范比较适用。但如果是浏览器环境,要从服务器加载模块,这是就必须采用异步模式。所以就有了 AMD、CMD 解决方案。

AMD、CMD

  • AMD 是 RequireJS 在推广过程中对模块定义的规范化产物。AMD 推崇提前执行。
 // AMD 默认推荐的是
define(['./a', './b'], function(a, b) {
  a.doSomething()
  b.doSomething()
  ...
})
  • CMD 是 SeaJS 在推广过程中对模块定义的规范化产物。CMD 推崇依赖就近。
// CMD
define(function(require, exports, module) {
  var a = require('./a')
  a.doSomething()
  var b = require('./b')
  b.doSomething()
  ...
})

UMD

UMD 是 AMD 和 CommonJS 的结合。因为 AMD 是以浏览器为出发点的异步加载模块,CommonJS 是以服务器为出发点的同步加载模块,所以人们想出了另一个更通用的模式 UMD,来解决跨平台的问题。

diana 库 选择了以 umd 方式进行输出,来看下 UMD 做了啥:

(function (root, factory) {
  if (typeof exports === 'object' && typeof module === 'object') { // UMD 先判断是否支持 Node.js 的模块(exports)是否存在,存在则使用 CommonJS 模式
    module.exports = factory()
  } else if (typeof define === 'function' && define.amd) { // 接着判断是否支持 AMD(define是否存在),存在则使用 AMD 方式加载模块。
    define([], factory)
  } else if (typeof exports === 'object') { // CommonJS 的另一种形式
    exports['diana'] = factory()
  } else
    root['diana'] = factory() // Window
})(this, function() {
  return module
})

测试踩坑之路

代码覆盖率

单元测试的代码覆盖率统计,是衡量测试用例好坏的一个的方法。但凡是线上用的库,基本上都少不了高质量的代码覆盖率的检测。如下图为 diana 库的测试覆盖率展示。

可以看到覆盖率分为以下 4 种类型,

  • 行覆盖率(line coverage):是否每一行都执行了?
  • 函数覆盖率(function coverage):是否每个函数都调用了?
  • 分支覆盖率(branch coverage):是否每个if代码块都执行了?
  • 语句覆盖率(statement coverage):是否每个语句都执行了?

番外:github 上显示的覆盖率是根据行覆盖率来展示的。
codecov

mocha + istanbul

最初的版本, 仅仅用到 mocha 进行测试 *.test.js 文件,然后在 codecov 得到测试覆盖率。

引人 karma

如果仅仅测试 es5、es6 的语法,其实用 mocha 就已经够用了,但是涉及到测试 Dom 操作的语法等就必须建立一个浏览器,在上面进行测试。karma 的作用其实就是自动帮我们建立一个测试用的浏览器环境。

为了让浏览器支持 Common.js 规范,中间用了 karma + browserify,尽管测试用例都跑通了,但是最后的代码覆盖率的文件里只有各个方法的引用路径。最后只能又回到 karma + webpack 来,这里又踩到一个坑,打包编译JS代码覆盖率问题,踩了一些坑后,终于实现了可以查看编译前代码的覆盖率。图如下:

通过这幅图我们能清晰地看到源代码中测试用例跑过各行代码的次数(左侧的数字),以及测试用例没有覆盖到的代码(图中红色所示)。然后我们就能改善相应的测试用例从而提高测试覆盖率。

配置文件,核心部分如下:

module.exports = function(config) {
  config.set({
    files: ['test/index.js'], // 需载入浏览器的文件
    preprocessors: { // 预处理
      'test/index.js': ['webpack', 'coverage']
    },
    webpack: {
      module: {
        rules: [{
          test: /\.js$/,
          use: { loader: 'sourcemap-istanbul-instrumenter-loader' }, // 这里用 istanbul-instrumenter-loader 插件的 0.0.2 版本,其它版本有坑~
          exclude: [/node_modules/, /\.spec.js$/],
        }],
      }
    },
    coverageReporter: {
      type: 'lcov', // 貌似只能支持这种类型的读取
      dir: 'coverage/'
    },
    remapIstanbulReporter: { // 生成 coverage 文件
      reports: {
        'text-summary': null,
        json: 'coverage/coverage.json',
        lcovonly: 'coverage/lcov.info',
        html: 'coverage/html/',
      }
    },
    reporters: ['progress', 'karma-remap-istanbul'], // remap-isbanbul 也报了一个未找到 sourcemap 的 error,直接注释了 remap-istanbul 包的 CoverageTransformer.js 文件的 169 行,以后有机会再捣鼓吧。(心累)
    ...
  })
}

总结

本文围绕 diana 库 对造轮子的意义,模块兼容性,测试用例进行了思考总结。后续会对该库流程自动化以及性能上做些分享。
该库参考学习了很多优秀的库,感谢 underscoreoutilsec-do30-seconds-of-code 等库对我的帮助。

最后欢迎各位大佬在 issues 尽情吐槽。

跨域实践二三事

跨域是日常开发中经常开发中经常会接触到的一个重难点知识,何不总结实践一番,从此心中对之了无牵挂。

同源策略

之所以会出现跨域解决方案,是因为同源策略的限制。同源策略规定了如果两个 url 的协议、域名、端口中有任何一个不等,就认定它们跨源了。比如下列表格列出和 http://127.0.0.1:3000 比较的同源检测的结果,

url 结果 原因
http://127.0.0.1:3000/index 同源
https://127.0.0.1:3000 跨源 协议不同
https://localhost:3000 跨源 域名不同
http://127.0.0.1:3001 跨源 端口不同

那跨源有什么后果呢?归纳有三:不能获取 Cookie、LocalStorage、IndexedDB;不能获取 dom 节点;不能进行一般的 Ajax 通信;跨域解决方案的出现就是为了解决以上痛处。

JSONP 跨域

提到 JSONP 跨域,不得不先提到 script 标签,和 imgiframe 标签类似,这些标签是不受同源策略限制的,JSONP 的核心就是通过动态加载 script 标签来完成对目标 url 的请求。

先来看一段 JSONP 调用的 Headers 部分,字段如下:

Request URL:http://127.0.0.1:3000/?callback=handleResponse
Request Method:GET
Status Code:200 OK
Remote Address:127.0.0.1:3000

可以很鲜明地发现在 Request URL 中有一句 ?callback=handleResponse,这个 callback 后面跟着的 handleResponse 即回调函数名(可以任意取),服务端会接收到这个参数然后拼接成形如 handleResponse(JSON) 的形式返还给前端(这也是 JSONP == JSON with padding 的原因吧),如下图,这时候浏览器就会自动调用我们事先定义好的 handleResponse 函数。

前端代码示例:(源为 http://127.0.0.1:3001)

function handleResponse(res) {
  console.log(res) // {text: "jsonp"}
}

const script = document.createElement('script')
script.src = 'http://127.0.0.1:3000?callback=handleResponse'
document.head.appendChild(script)

服务端代码示例:(源为 http://127.0.0.1:3000)

const server = http.createServer((req, res) => {
  if (~req.url.indexOf('?callback')) { // 简单处理 JSONP 跨域的时候
    const obj = {
      "text": 'jsonp',
    }
    const callback = req.url.split('callback=')[1]
    const json = JSON.stringify(obj)
    const build = callback + `(${json})`
    res.end(build) // 这里返还给前端的是拼接好的 JSON 对象
  }
});

可以看出 JSONP 具有直接访问响应文本的优点,但是要想确认 JSONP 是否请求失败并不容易,因为 script 标签的 onerror 事件还未得到浏览器广泛的支持,此外它仅能支持 GET 方式调用。

CORS 跨域

CORS(Cross-Origin Resource Sharing) 可以理解为加强版的 Ajax,也是目前主流的跨域解决方案。它的核心**即前端与后端进行 Ajax 通信时,通过自定义 HTTP 头部设置从而决定请求或响应是否生效

比如前端代码(url 为 http://127.0.0.1:3001)写了段 Ajax,代码如下:

const xhr = new XMLHttpRequest()
xhr.onreadystatechange = function () {
  if (xhr.readyState === 4) {
    if (xhr.status >= 200 && xhr.status < 300 || xhr.status === 304) {
      console.log('responseTesx:' + xhr.responseText)
    }
  }
}
xhr.open('get', 'http://127.0.0.1:3000', true)
xhr.send()

因为端口不一致的关系这时候导致不同源了,这时候会在 Request Headers 中发现多了这么一行字段,

Origin: http://127.0.0.1:3001

而且控制台中会报出如下错误:

Failed to load http://127.0.0.1:3000/: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://127.0.0.1:3001' is therefore not allowed access.

这时候就需要在服务端设置字段 Access-Control-Allow-Origin,它的作用就是设置允许来自什么源的请求,如果值设置为 *,表明允许来自任意源的请求。服务端代码示例如下:

http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3001') // 设置允许来自 http://127.0.0.1:3001 源的请求
})

CORS 分为简单请求以及非简单请求。可以这么区分,如果请求方法为 POSTGETHEAD 时为简单请求,其它方法如 PUTDELETE 等为非简单请求,如果是非简单请求的话,可以在 chrome 的 Network 中看到多了一次 Request MethodOPTIONS 的请求。如下图:

可以把这个请求称为预请求,用白话文翻译下,浏览器询问服务器,'服务器大哥,我这次要进行 PUT 请求,你给我发张通行证呗',服务器大哥见浏览器小弟这么殷勤,于是给了它发了张通行证,叫作 Access-Control-Allow-Methods:PUT,接着浏览器就能愉快地进行 PUT 请求了。服务端代码示例如下:

http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3001')
  res.setHeader('Access-Control-Allow-Methods', 'http://127.0.0.1:3001')
})

聊完简单请求和非简单请求的区别后,再来看看如何利用 CORS 实现 Cookie 的跨域传送,首先在服务器随意设置个 Cookie 值下发到浏览器,如果非跨域的情况下,浏览器再次请求服务器时就会带上服务器给的 Cookie,但是跨域的时候怎么办呢?不卖关子了,需在服务端设置 Access-Control-Allow-Credentials 字段以及在客户端设置 withCredentials 字段,两者缺一不可,代码如下:

前端代码示例:(源为 http://127.0.0.1:3001)

const xhr = new XMLHttpRequest()
...
xhr.withCredentials = true // 传 cookie 的时候前端要做的
xhr.open('get', 'http://127.0.0.1:3000', true)
xhr.send()

服务端代码示例: (源为 http://127.0.0.1:3000)

const server = http.createServer((req, res) => {
  res.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:3001') // 必填:接受域的请求
  res.setHeader('Set-Cookie', ['type=muyy']) // 下发 cookie
  res.setHeader('Access-Control-Allow-Credentials', true) // ② 选填:是否允许浏览器传 cookie 到服务端,只能设置为 true
  res.end('date from cors')
})

至此介绍了几个比较关键 HTTP 头在 CORS 中的实践运用,更为详细的资料可以参阅 Cross-Origin Resource Sharing,最后概括下 CORS 的优缺点,优点是支持所有类型的 HTTP 方法,缺点是有些老的浏览器不支持 CORS。

hash + iframe

在文章最开始提到过 iframe 标签也是不受同源策略限制的标签之一,hash + iframe 的跨域核心**就是,在 A 源中通过动态改变 iframe 标签的 src 的哈希值,在 B 源中通过 window.onhashchange 来捕获到相应的哈希值。思路不难直接上代码:

A 页面代码示例(源为 http://127.0.0.1:3000)

<body>
  <iframe src="http://127.0.0.1:3001"></iframe>
  <script>
    const iframe = document.getElementsByTagName('iframe')[0]
    iframe.setAttribute('style', 'display: none')
    const obj = {
      data: 'hash'
    }
    iframe.src = iframe.src + '#' + JSON.stringify(obj) // ① 关键语句
  </script>
</body>

B 页面代码示例(源为 http://127.0.0.1:3001)

window.onhashchange = function() { // ① 关键语句
  console.log('来自 page2 的代码 ' + window.location.hash) // 来自 page2 的代码 #{"data":"hash"}
}

刷新 A 页面,可以发现在控制台打印了如下字段,至此实现了跨域。

来自 page2 的代码 #{"data":"hash"}

这种方式进行跨域优点是支持页面和页面间的通信,缺点也是只支持 GET 方法和单向的跨域通信。

postMessage

为了实现跨文档传送(cross-document messaging),简称 XDM。HTML5 给出了一个 api —— postMessage,postMessage() 方法接收两个参数:发送消息以及消息接收方所在域的字符串。代码示例如下:

A 页面代码示例(源为 http://127.0.0.1:3000)

<body>
  <iframe src="http://127.0.0.1:3001"></iframe>
  <script>
    const iframe = document.getElementsByTagName('iframe')[0]
    iframe.setAttribute('style', 'display: none')
    iframe.onload = function() { // 此处要等 iframe 加载完毕,后续代码才会生效
      iframe.contentWindow.postMessage('a secret', 'http://127.0.0.1:3001')
    }
  </script>
</body>

B 页面代码示例(源为 http://127.0.0.1:3001)

window.addEventListener('message', function(event) {
  console.log('From page1 ' + event.data)
  console.log('From page1 ' + event.origin)
}, false)

刷新 A 页面,可以发现在控制台打印了如下字段,至此实现了跨域。

From page1 a secret
From page1 http://127.0.0.1:3000

这种跨域方式优点是是支持页面和页面间的双向通信,缺点也是只能支持 GET 方法调用。

WebSockets

WebSockets 属于 HTML5 的协议,它的目的是在一个持久连接上建立全双工通信。由于 WebSockets 采用了自定义协议,所以优点是客户端和服务端发送数据量少,缺点是要额外的服务器。基础的使用方法如下:

const ws = new WebSocket('ws://127.0.0.1:3000')
ws.onopen = function() {
  // 连接成功建立
}

ws.onmessage = function(event) {
  // 处理数据
}

ws.onerror = function() {
  // 发生错误时触发,连接中断
}

ws.onclose = function() {
  // 连接关闭时触发
}

当然一般我们会使用封装好 WebSockets 的第三方库 socket.io,这里具体就不展开了。

项目地址

前文所述五种跨域实践的 demo 已上传至 cross-domain,前端环境基于 create-react-app 搭建,后端环境用 node 搭建。

当然跨域方式还有一些其他方式的实现,后续酌情慢慢填坑~

6.onChange 事件以及受控组件

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

从一个疑问点开始

接上一章 HOC 探索 抛出的问题 ———— react 中的 onChange 事件和原生 DOM 事件中的 onchange 表现不一致,举例说明如下:

// React 中的 onChange 事件
class App extends Component {
  constructor(props) {
    super(props)
    this.onChange = this.onChange.bind(this)
  }

  onChange(e) {
    console.log('键盘松开立刻执行')
  }

  render() {
    return (
      <input onChange={this.onChange} />
    )
  }
}

/*--------------分割线---------------*/

// 原生 DOM 事件中的 onchange 事件:<input id='test'>
document.getElementById('test').addEventListener('change', (e) => {
  console.log('键盘松开以后还需按下回车键或者点下鼠标才会触发')
})

拨云见雾

我们来看下 React 的一个 issue React Fire: Modernizing React DOM。有两点信息和这篇文章的话题相关。

  • Drastically simplify the event system
  • Migrate from onChange to onInput and don’t polyfill it for uncontrolled components

从这两点内容我们可以得知下面的信息:

React 实现了一套合成事件机制,也就是它的事件机制和原生事件间会有不同。比如它目前 onChange 事件其实对应着原生事件中的 input 事件。在这个 issue 中明确了未来会使用 onInput 事件替代 onChange 事件,并且会大幅度地简化合成事件。

有了以上信息后,我们对 onChange 事件(将来的 onInput 事件)的代码作如下更改:

function setAttribute(dom, attr, value) {
  ...
  if (attr.match(/on\w+/)) {        // 处理事件的属性:
    let eventName = attr.toLowerCase().substr(2)
    if (eventName === 'change') { eventName = 'input' } // 和现阶段的 react 统一
    dom.addEventListener(eventName, value)
  }
  ...
}

自由组件以及受控组件

区分自由组件以及受控组件在于表单的值是否由 value 这个属性控制,比较如下代码:

const case1 = () => <input />                    // 此时输入框内可以随意增减任意值
const case2 = () => <input defaultValue={123} /> // 此时输入框内显示 123,能随意增减值
const case3 = () => <input value={123} />        // 此时输入框内显示 123,并且不能随意增减值

case3 的情形即为简化版的受控组件。

受控组件的实现

题目可以换个问法:当 input 的传入属性为 value 时(且没有 onChange 属性),如何禁用用户的输入事件的同时又能获取焦点?

首先想到了 html 自带属性 readonly、disable,它们都能禁止用户的输入,但是它们不能满足获取焦点这个条件。结合前文 onChange 的实现是监听 input 事件,代码分为以下两种情况:

1.dom 节点包含 value 属性、onChange 属性
2.dom 节点包含 value 属性,不包含 onChange 属性

代码如下:

function vdomToDom(vdom) {
  ...
  if (vdom.attributes
    && vdom.attributes.hasOwnProperty('onChange')
    && vdom.attributes.hasOwnProperty('value')) { // 受控组件逻辑
      ...
      dom.addEventListener('input', (e) => {
        changeCb.call(this, e)
        dom.value = oldValue
      })
      ...
    }
  if (vdom.attributes
    && !vdom.attributes.hasOwnProperty('onChange')
    && vdom.attributes.hasOwnProperty('value')) { // 受控组件逻辑
    ...
    dom.addEventListener('input', (e) => {
      dom.value = oldValue
    })
    ...
  }
  ...
}

可以发现它们的核心都在这段代码上:

dom.addEventListener('input', (e) => {
  changeCb.call(this, e)
  dom.value = oldValue
})

区别是当有 onChange 属性 时,能提供相应的回调函数 changeCb 通过事件循环机制改变表单的值。看如下两个例子的比较:

const App = () => <input value={123} />

效果如下:

class App extends Component {
  constructor() {
    super()
    this.state = { num: 123 }
    this.change = this.change.bind(this)
  }

  change(e) {
    this.setState({
      num: e.target.value
    })
  }

  render() {
    return (
      <div>
        <input value={this.state.num} onChange={this.change} />
      </div>
    )
  }
}

这段代码中的 change 函数即上个段落所谓的 changeCb 函数,通过 setState 的事件循环机制改变表单的值。

效果如下:

至此,模拟了受控组件的实现。

js设计模式的单例模式有一处少写

const createLoginLayer = function () {
   const div = document.createElement('div');
   div.innerHTML = '登陆弹窗';
   document.appendChild(div); // 改为document.body.appendChild(div)
   return div
 }

5.PureComponent 实现 && HOC 探幽

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

PureComponent 精髓

使用 PureComponent 是优化 React 性能的一种常用手段,相较于 Component, PureComponent 会在 render 之前自动执行一次 shouldComponentUpdate() 函数,根据返回的 bool 值判断是否进行 render。其中有个重点是 PureComponent 在 shouldComponentUpdate() 的时候会进行 shallowEqual(浅比较)。

PureComponent 的浅比较策略如下:

对 prevState/nextState 以及 prevProps/nextProps 这两组数据进行浅比较:

1.对象第一层数据未发生改变,render 方法不会触发;
2.对象第一层数据发生改变(包括第一层数据引用的改变),render 方法会触发;

PureComponent 的实现

照着上述思路我们来实现 PureComponent 的逻辑

function PureComponent(props) {
  this.props = props || {}
  this.state = {}

  isShouldComponentUpdate.call(this) // 为每个 PureComponent 绑定 shouldComponentUpdate 方法
}

PureComponent.prototype.setState = function(updater, cb) {
  isShouldComponentUpdate.call(this) // 调用 setState 时,让 this 指向子类的实例,目的取到子类的 this.state
  asyncRender(updater, this, cb)
}

function isShouldComponentUpdate() {
  const cpState = this.state
  const cpProps = this.props
  this.shouldComponentUpdate = function (nextProps, nextState) {
    if (!shallowEqual(cpState, nextState) || !shallowEqual(cpProps, nextProps)) {
      return true  // 只要 state 或 props 浅比较不等的话,就进行渲染
    } else {
      return false // 浅比较相等的话,不渲染
    }
  }
}

// 浅比较逻辑
const shallowEqual = function(oldState, nextState) {
  const oldKeys = Object.keys(oldState)
  const newKeys = Object.keys(nextState)

  if (oldKeys.length !== newKeys.length) {
    return false
  }

  let flag = true
  for (let i = 0; i < oldKeys.length; i++) {
    if (!nextState.hasOwnProperty(oldKeys[i])) {
      flag = false
      break
    }

    if (nextState[oldKeys[i]] !== oldState[oldKeys[i]]) {
      flag = false
      break
    }
  }

  return flag
}

测试用例

测试用例用 在 React 上提的一个 issue 中的案例,我们期望点击增加按钮后,页面上显示的值能够加 1。

class B extends PureComponent {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    this.setState({
      count: ++this.state.count,
    })
  }

  render() {
    return (
      <div>
        <button onClick={this.click}>增加</button>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

然而,我们点击上述代码,页面上显示的 0 分毫不动!!!

揭秘如下:

click() {
  const t = ++this.state.count
  console.log(t === this.state.count) // true
  this.setState({
    count: t,
  })
}

当点击增加按钮,控制台显示 t === this.state.count 为 true, 也就说明了 setState 前后的状态是统一的,所以 shallowEqual(浅比较) 返回的是 true,致使 shouldComponentUpdate 返回了 false,页面因此没有渲染。

类似的,如下写法也是达不到目标的,留给读者思考了。

click() {
  this.setState({
    count: this.state.count++,
  })
}

那么如何达到我们期望的目标呢。揭秘如下:

click() {
  this.setState({
    count: this.state.count + 1
  })
}

感悟:小小的一行代码里蕴藏着无数的 bug。

HOC 实践

高阶组件(Higher Order Component) 不属于 React API 范畴,但是它在 React 中也是一种实用的技术,它可以将常见任务抽象成一个可重用的部分。这个小节算是番外篇,会结合 cpreact(前文实现的类 react 轮子) 与 HOC 进行相关的实践。

它可以用如下公式表示:

y = f(x),

// x:原有组件
// y:高阶组件
// f():

f() 的实现有两种方法,下面进行实践。

属性代理(Props Proxy)

这类实现也是装饰器模式的一种运用,通过装饰器函数给原来函数赋能。下面例子在装饰器函数中给被装饰的组件传递了额外的属性 { a: 1, b: 2 }。

声明:下文所展示的 demo 均已在 cpreact 测试通过

function ppHOC(WrappedComponent) {
  return class extends Component {

    render() {
      const obj = { a: 1, b: 2 }
      return (
        <WrappedComponent { ...this.props } { ...obj } />
      )
    }
  }
}

@ppHOC
class B extends Component {
  render() {
    return (
      <div>
        { this.props.a + this.props.b } { /* 输出 3 */ }
      </div>
    )
  }
}

要是将 { a: 1, b: 2 } 替换成全局共享对象,那么不就是 react-redux 中的 Connect 了么?

改进上述 demo,我们就可以实现可插拔的受控组件,代码示意如下:

function ppDecorate(WrappedComponent) {
  return class extends Component {
    constructor() {
      super()
      this.state = {
        value: ''
      }
      this.onChange = this.onChange.bind(this)
    }

    onChange(e) {
      this.setState({
        value: e.target.value
      })
    }

    render() {
      const obj = {
        onChange: this.onChange,
        value: this.state.value,
      }

      return (
        <WrappedComponent { ...this.props } { ...obj } />
      )
    }
  }
}

@ppDecorate
class B extends Component {
  render() {
    return (
      <div>
        <input { ...this.props } />
        <div>{ this.props.value }</div>
      </div>
    )
  }
}

效果如下图:

这里有个坑点,当我们在输入框输入字符的时候,并不会立马触发 onChange 事件(我们想要让事件立即触发,然而现在要按下回车键或者点下鼠标才触发),在 react 中有个合成事件 的知识点,下篇文章会对 react 中的 onChange 事件为何和原生 DOM 事件中的 onchange 表现不一致进行探究揭秘。

顺带一提在这个 demo 中似乎看到了双向绑定的效果,但是实际中 React 并没有双向绑定的概念,但是我们可以运用 HOC 的知识点结合 setState 在 React 表单中实现伪双向绑定的效果。

继承反转(Inheritance Inversion)

继承反转的核心是:传入 HOC 的组件会作为返回类的父类来使用。然后在 render 中调用 super.render() 来调用父类的 render 方法。

《ES6 继承与 ES5 继承的差异》中我们提到了作为对象使用的 super 指向父类的实例。

function iiHOC(WrappedComponent) {
  return class extends WrappedComponent {
    render() {
      const parentRender = super.render()
      if (parentRender.nodeName === 'span') {
        return (
          <span>继承反转</span>
        )
      }
    }
  }
}

@iiHOC
class B extends Component {
  render() {
    return (
      <span>Inheritance Inversion</span>
    )
  }
}

在这个 demo 中,在 HOC 内实现了渲染劫持,页面上最终显示如下:

可能会有疑惑,使用属性代理的方式貌似也能实现渲染劫持呀,但是那样做没有继承反转这种方式纯粹。

相关链接

拼多多内推(长期有效)

社招

拼多多 - 商户前端团队(其它团队也可以帮推)

  • 团队大佬: 大 boss 为 dubbo 框架作者 https://github.com/apache/dubbo
  • 与自己关系:所在业务线招人, 目前有大量 hc
  • 岗位名称:
    • 资深前端工程师、前端架构师/技术专家
  • 薪资带宽:很多~无上限
  • 工作城市:上海金虹桥
  • 工作内容:拼多多商家平台 PC、移动、小程序、Node 服务等多端技术的系统研发工作
  • 内推人:我
  • 联系方式:发送简历到 [email protected] 或者加微信联系 a328375795

职能介绍

资深前端工程师

职能介绍

  • 负责拼多多商家平台涉及 PC 、移动、小程序、Node 服务等多端技术的系统研发工作;
  • 团队注重商家用户体验的提升,对前端体验与性能优化做到极致,对代码、设计与交互有洁癖和一定追求;
  • 技术栈涉及整个前端体系,包括微前端框架、移动端组件包、自研服务端渲染框架;
  • 技术资产包括但不限于 UI 组件库、脚手架、构建工具、数据可视化、通用模块等基础设施的建设;
  • 用打造产品的思维,从业务、设计、架构层面思考如何拆解,更高的复用,更有效的实现;
  • 具备产品工程师思维,注重积累与技术创新,与团队一起打造技术产品,提升行业技术影响力;

岗位要求

  • 本科及以上学历,计算机专业领域基础扎实,2年以上Web开发经验;
  • 熟练使用 ECMA-262 及以上版本及 TypeScript 语言进行开发;
  • 掌握常用 IDE 配置,常规 Lint 类工具及类型检查、持续集成工具的使用;
  • 熟练使用业界常用 JavaScript 库或框架(React 为主 / Vue / Angular),熟练掌握 Hooks 的使用;
  • 熟悉 HTTP 规范,对 HTTP/2、GraphQL、REST 有自己的理解;
  • 熟悉常用单元测试、集成测试框架的基本使用;
  • 熟悉前端工程化解决方案(Webpack / Rollup.js)的使用和基本原理;
  • 具备一定的代码洁癖与良好的技术总结能力;

加分项

  • 对服务端渲染技术(SSR)、服务端技术(Node.js)、微前端框架有开发经验加分;
  • 熟悉电商领域业务经验加分;
  • 对线上系统异常监控、巡检、自动化测试有涉猎加分;
  • 有大规模前端项目、UI 组件开发、复杂交互、复杂状态系统的开发经验加分;
  • 从事过服务端、客户端、用户体验设计,具备相应技能加分;
  • 熟练使用函数式编程风格或面向对象编程风格开发加分;
  • 对前端领域技术(如:响应式编程、微前端架构、Schema AST 等)有深入理解加分;
  • 具备数据可视化,可视化构建系统建设,浏览器插件开发经验加分;
前端架构师/技术专家

职能介绍

  • 负责拼多多商家平台涉及 PC 、移动、小程序、Node 服务等多端技术的系统研发工作;
  • 团队注重商家用户体验的提升,对前端体验与性能优化做到极致,对代码、设计与交互有洁癖和一定追求;
  • 技术栈涉及整个前端体系,包括微前端框架、移动端组件包、自研服务端渲染框架;
  • 技术资产包括但不限于 UI 组件库、脚手架、构建工具、数据可视化、通用模块等基础设施的建设;
  • 优化前端团队的开发方式和流程、规范,提高团队效率;
  • 用打造产品的思维,从业务、设计、架构层面思考如何拆解,更高的复用,更有效的实现;
  • 具备产品工程师思维,注重积累与技术创新,与团队一起打造技术产品,提升行业技术影响力;

岗位要求

  • 本科及以上学历,计算机专业领域基础扎实,2年以上Web开发经验,掌握常用数据结构与算法;
  • 熟练使用 ECMA-262 及以上版本及 TypeScript 语言进行开发,熟悉模块化加载机制;
  • 掌握常用 IDE 配置,常规 Lint 类工具及类型检查、持续集成工具的使用及二次开发;
  • 熟练使用业界常用 JavaScript 库或框架(React 为主 / Vue / Angular),熟练掌握 Hooks 的使用;
  • 熟悉 HTTP 规范,对 HTTP/2、GraphQL、REST 有自己的理解与使用经验;
  • 熟悉常用单元测试、集成测试框架的基本使用,并能进行二次封装;
  • 熟练掌握 CSS 样式工具库的使用,具备优秀的样式封装与复用能力;
  • 掌握集成测试、线上部署、性能监控及调优等技术方案;
  • 熟悉前端工程化解决方案(Webpack / Rollup.js)的使用和基本原理,了解 Loader / Plugin 的原理及实现;
  • 具备一定的代码洁癖与良好的技术总结能力;
  • 具备良好的性能调优与 Web 网络安全意识;

加分项

  • 对服务端渲染技术(SSR)、服务端技术(Node.js)、微前端框架有开发经验加分;
  • 熟悉电商领域业务经验加分;
  • 有大规模前端项目、UI 组件开发、复杂交互、复杂状态系统的开发经验加分;
  • 对线上系统异常监控、巡检、自动化测试有涉猎加分;
  • 从事过服务端、客户端、用户体验设计,具备相应技能加分;
  • 熟练使用函数式编程风格或面向对象编程风格开发加分;
  • 对前端领域技术(如:响应式编程、微前端架构、Schema AST 等)有深入理解加分;
  • 参与效率工程相关技术,具备数据可视化,可视化构建,浏览器插件开发经验加分;

设计模式--单例篇 Error: Missing initializer in const declaration

文章有描述错误的地方欢迎指正交流。

error:

const getSingle = function(fn) {
  const result
  return function() {
    return result || result = fn.apply(this, arguments)
  }
};

update:

const getSingle = function(fn) {
  var result;
  return function() {
    if(result){
     return result 
    }
    return result = fn.apply(this, arguments)
  }
};

探寻 webpack 插件机制

webpack 可谓是让人欣喜又让人忧,功能强大但需要一定的学习成本。在探寻 webpack 插件机制前,首先需要了解一件有意思的事情,webpack 插件机制是整个 webpack 工具的骨架,而 webpack 本身也是利用这套插件机制构建出来的。因此在深入认识 webpack 插件机制后,再来进行项目的相关优化,想必会大有裨益。

webpack 插件

先来瞅瞅 webpack 插件在项目中的运用

const MyPlugin = require('myplugin')
const webpack = require('webpack')

webpack({
  ...,
  plugins: [new MyPlugin()]
  ...,
})

那么符合什么样的条件能作为 webpack 插件呢?一般来说,webpack 插件有以下特点:

  1. 独立的 JS 模块,暴露相应的函数

  2. 函数原型上的 apply 方法会注入 compiler 对象

  3. compiler 对象上挂载了相应的 webpack 事件钩子

  4. 事件钩子的回调函数里能拿到编译后的 compilation 对象,如果是异步钩子还能拿到相应的 callback

下面结合代码来看看:

function MyPlugin(options) {}
// 2.函数原型上的 apply 方法会注入 compiler 对象
MyPlugin.prototype.apply = function(compiler) {
  // 3.compiler 对象上挂载了相应的 webpack 事件钩子 4.事件钩子的回调函数里能拿到编译后的 compilation 对象
  compiler.plugin('emit', (compilation, callback) => {
    ...
  })
}
// 1.独立的 JS 模块,暴露相应的函数
module.exports = MyPlugin

这样子,webpack 插件的基本轮廓就勾勒出来了,此时疑问点有几点,

  1. 疑问 1:函数的原型上为什么要定义 apply 方法?阅读源码后发现源码中是通过 plugin.apply() 调用插件的。
const webpack = (options, callback) => {
  ...
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}
  1. 疑问 2:compiler 对象是什么呢?

  2. 疑问 3:compiler 对象上的事件钩子是怎样的?

  3. 疑问 4:事件钩子的回调函数里能拿到的 compilation 对象又是什么呢?

这些疑问也是本文的线索,让我们一个个探索。

compiler 对象

compiler 即 webpack 的编辑器对象,在调用 webpack 时,会自动初始化 compiler 对象,源码如下:

// webpack/lib/webpack.js
const Compiler = require("./Compiler")

const webpack = (options, callback) => {
  ...
  options = new WebpackOptionsDefaulter().process(options) // 初始化 webpack 各配置参数
  let compiler = new Compiler(options.context)             // 初始化 compiler 对象,这里 options.context 为 process.cwd()
  compiler.options = options                               // 往 compiler 添加初始化参数
  new NodeEnvironmentPlugin().apply(compiler)              // 往 compiler 添加 Node 环境相关方法
  for (const plugin of options.plugins) {
    plugin.apply(compiler);
  }
  ...
}

终上,compiler 对象中包含了所有 webpack 可配置的内容,开发插件时,我们可以从 compiler 对象中拿到所有和 webpack 主环境相关的内容。

compilation 对象

compilation 对象代表了一次单一的版本构建和生成资源。当运行 webpack 时,每当检测到一个文件变化,一次新的编译将被创建,从而生成一组新的编译资源。一个编译对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。

结合源码来理解下上面这段话,首先 webpack 在每次执行时会调用 compiler.run() (源码位置),接着追踪 onCompiled 函数传入的 compilation 参数,可以发现 compilation 来自构造函数 Compilation。

// webpack/lib/Compiler.js
const Compilation = require("./Compilation");

newCompilation(params) {
  const compilation = new Compilation(this);
  ...
  return compilation;
}

不得不提的 tapable 库

再介绍完 compiler 对象和 compilation 对象后,不得不提的是 tapable 这个库,这个库暴露了所有和事件相关的 pub/sub 的方法。而且函数 Compiler 以及函数 Compilation 都继承自 Tapable。

事件钩子

事件钩子其实就是类似 MVVM 框架的生命周期函数,在特定阶段能做特殊的逻辑处理。了解一些常见的事件钩子是写 webpack 插件的前置条件,下面列举些常见的事件钩子以及作用:

钩子 作用 参数 类型
after-plugins 设置完一组初始化插件之后 compiler sync
after-resolvers 设置完 resolvers 之后 compiler sync
run 在读取记录之前 compiler async
compile 在创建新 compilation 之前 compilationParams sync
compilation compilation 创建完成 compilation sync
emit 在生成资源并输出到目录之前 compilation async
after-emit 在生成资源并输出到目录之后 compilation async
done 完成编译 stats sync

完整地请参阅官方文档手册,同时浏览相关源码 也能比较清晰地看到各个事件钩子的定义。

插件流程浅析

拿 emit 钩子为例,下面分析下插件调用源码:

compiler.plugin('emit', (compilation, callback) => {
  // 在生成资源并输出到目录之前完成某些逻辑
})

此处调用的 plugin 函数源自上文提到的 tapable 库,其最终调用栈指向了 hook.tapAsync(),其作用类似于 EventEmitter 的 on,源码如下:

// Tapable.js
options => {
  ...
  if(hook !== undefined) {
    const tapOpt = {
      name: options.fn.name || "unnamed compat plugin",
      stage: options.stage || 0
    };
    if(options.async)
      hook.tapAsync(tapOpt, options.fn); // 将插件中异步钩子的回调函数注入
    else
      hook.tap(tapOpt, options.fn);
    return true;
  }
};

有注入必有触发的地方,源码中通过 callAsync 方法触发之前注入的异步事件,callAsync 类似 EventEmitter 的 emit,相关源码如下:

this.hooks.emit.callAsync(compilation, err => {
	if (err) return callback(err);
	outputPath = compilation.getPath(this.outputPath);
	this.outputFileSystem.mkdirp(outputPath, emitFiles);
});

一些深入细节这里就不展开了,说下关于阅读比较大型项目的源码的两点体会,

  • 要抓住一条主线索去读,忽视细节。否则会浪费很多时间而且会有挫败感;

  • 结合调试工具来分析,很多点不用调试工具的话很容易顾此失彼;

动手实现个 webpack 插件

结合上述知识点的分析,不难写出自己的 webpack 插件,关键在于想法。为了统计项目中 webpack 各包的有效使用情况,在 fork webpack-visualizer 的基础上对代码升级了一番,项目地址。效果如下:

插件核心代码正是基于上文提到的 emit 钩子,以及 compiler 和 compilation 对象。代码如下:

class AnalyzeWebpackPlugin {
  constructor(opts = { filename: 'analyze.html' }) {
    this.opts = opts
  }

  apply(compiler) {
    const self = this
    compiler.plugin("emit", function (compilation, callback) {
      let stats = compilation.getStats().toJson({ chunkModules: true }) // 获取各个模块的状态
      let stringifiedStats = JSON.stringify(stats)
      // 服务端渲染
      let html = `<!doctype html>
          <meta charset="UTF-8">
          <title>AnalyzeWebpackPlugin</title>
          <style>${cssString}</style>
          <div id="App"></div>
          <script>window.stats = ${stringifiedStats};</script>
          <script>${jsString}</script>
      `
      compilation.assets[`${self.opts.filename}`] = { // 生成文件路径
        source: () => html,
        size: () => html.length
      }
      callback()
    })
  }
}

参考资料

看清楚真正的 Webpack 插件

webpack 官网

4.优化setState和ref的实现

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

同步 setState 的问题

而在现有 setState 逻辑实现中,每调用一次 setState 就会执行 render 一次。因此在如下代码中,每次点击增加按钮,因为 click 方法里调用了 10 次 setState 函数,页面也会被渲染 10 次。而我们希望的是每点击一次增加按钮只执行 render 函数一次。

export default class B extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    for (let i = 0; i < 10; i++) {
      this.setState({ // 在先前的逻辑中,没调用一次 setState 就会 render 一次
        count: ++this.state.count
      })
    }
  }

  render() {
    console.log(this.state.count)
    return (
      <div>
        <button onClick={this.click}>增加</button>
        <div>{this.state.count}</div>
      </div>
    )
  }
}

异步调用 setState

查阅 setState 的 api,其形式如下:

setState(updater, [callback])

它能接收两个参数,其中第一个参数 updater 可以为对象或者为函数 ((prevState, props) => stateChange),第二个参数为回调函数;

确定优化思路为:将多次 setState 后跟着的值进行浅合并,并借助事件循环等所有值合并好之后再进行渲染界面。

let componentArr = []

// 异步渲染
function asyncRender(updater, component, cb) {
  if (componentArr.length === 0) {
    defer(() => render())       // 利用事件循环,延迟渲染函数的调用
  }

  if (cb) defer(cb)             // 调用回调函数
  if (_.isFunction(updater)) {  // 处理 setState 后跟函数的情况
    updater = updater(component.state, component.props)
  }
  // 浅合并逻辑
  component.state = Object.assign({}, component.state, updater)
  if (componentArr.includes(component)) {
    component.state = Object.assign({}, component.state, updater)
  } else {
    componentArr.push(component)
  }
}

function render() {
  let component
  while (component = componentArr.shift()) {
    renderComponent(component) // rerender
  }
}

// 事件循环,关于 promise 的事件循环和 setTimeout 的事件循环后续会单独写篇文章。
const defer = function(fn) {
  return Promise.resolve().then(() => fn())
}

此时,每点击一次增加按钮 render 函数只执行一次了。

ref 的实现

在 react 中并不建议使用 ref 属性,而应该尽量使用状态提升,但是 react 还是提供了 ref 属性赋予了开发者操作 dom 的能力,react 的 ref 有 stringcallbackcreateRef 三种形式,分别如下:

// string 这种写法未来会被抛弃
class MyComponent extends Component {
  componentDidMount() {
    this.refs.myRef.focus()
  }
  render() {
    return <input ref="myRef" />
  }
}

// callback(比较通用)
class MyComponent extends Component {
  componentDidMount() {
    this.myRef.focus()
  }
  render() {
    return <input ref={(ele) => {
      this.myRef = ele
    }} />
  }
}

// react 16.3 增加,其它 react-like 框架还没有同步
class MyComponent extends Component {
  constructor() {
    super() {
      this.myRef = React.createRef()
    }
  }
  componentDidMount() {
    this.myRef.current.focus()
  }
  render() {
    return <input ref={this.myRef} />
  }
}

React ref 的前世今生 罗列了三种写法的差异,下面对上述例子中的第二种写法(比较通用)进行实现。

首先在 setAttribute 方法内补充上对 ref 的属性进行特殊处理,

function setAttribute(dom, attr, value) {
  ...
  else if (attr === 'ref') {          // 处理 ref 属性
    if (_.isFunction(value)) {
      value(dom)
    }
  }
  ...
}

针对这个例子中 this.myRef.focus() 的 focus 属性需要异步处理,因为调用 componentDidMount 的时候,界面上还未添加 dom 元素。处理 renderComponent 函数:

function renderComponent(component) {
  ...
  else if (component && component.componentDidMount) {
    defer(component.componentDidMount.bind(component))
  }
  ...
}

刷新页面,可以发现 input 框已为选中状态。

处理完普通元素的 ref 后,再来处理下自定义组件的 ref 的情况。之前默认自定义组件上是没属性的,现在只要针对自定义组件的 ref 属性做相应处理即可。稍微修改 vdomToDom 函数如下:

function vdomToDom(vdom) {
  if (_.isFunction(vdom.nodeName)) { // 此时是自定义组件
    ...
    for (const attr in vdom.attributes) { // 处理自定义组件的 ref 属性
      if (attr === 'ref' && _.isFunction(vdom.attributes[attr])) {
        vdom.attributes[attr](component)
      }
    }
    ...
  }
  ...
}

跑如下测试用例:

class A extends Component {
  constructor() {
    super()
    this.state = {
      count: 0
    }
    this.click = this.click.bind(this)
  }

  click() {
    this.setState({
      count: ++this.state.count
    })
  }

  render() {
    return <div>{this.state.count}</div>
  }
}

class B extends Component {
  constructor() {
    super()
    this.click = this.click.bind(this)
  }

  click() {
    this.A.click()
  }

  render() {
    return (
      <div>
        <button onClick={this.click}>加1</button>
        <A ref={(e) => { this.A = e }} />
      </div>
    )
  }
}

效果如下:

鸣谢

Especially thank simple-react for the guidance function of this library. At the meantime,respect for preact and react

走近 Python (类比 JS)

Python 是一门运用很广泛的语言,自动化脚本、爬虫,甚至在深度学习领域也都有 Python 的身影。作为一名前端开发者,也了解 ES6 中的很多特性借鉴自 Python (比如默认参数、解构赋值、Decorator等),同时本文会对 Python 的一些用法与 JS 进行类比。不管是提升自己的知识广度,还是更好地迎接 AI 时代,Python 都是一门值得学习的语言。

数据类型

在 Python 中,最常用的能够直接处理的数据类型有以下几种:

  • 数字[整数(int)、浮点型(float)、长整型(long)、复数(complex)]
  • 字符串(str)
  • 布尔值(bool)
  • 空值(None)

除此之外,Python 还提供了列表[list]、字典[dict] 等多种数据类型,这在下文中会介绍。

类型转换与类型判断

与 JS 十分类似,python 也能实现不同数据类型间的强制与隐式转换,例子如下:

强制类型转换:

int('3') # 3
str(3.14) # '3.14'
float('3.14') # 3.14
# 区别于 JS 只有 Number 一种类型,Python 中数字中的不同类型也能相互强制转换
float(3) # 3.0
bool(3) # True
bool(0) # False

隐式类型转换:

1 + 1.0 # 2.0
1 + False # 1
1.0 + True # 2.0
# 区别于 JS 的 String + Number = String, py 中 str + int 会报错
1 + '1' # TypeError: cannot concatenate 'str' and 'int' objects

此外写代码的时候经常会需要判断值的类型,可以 使用 python 提供的 type() 函数获取变量的类型,或者使用 isinstance(x, type) 来判断 x 是否属于相应的 type 类型。

type(1.3) == float # True
isinstance('a', str) # True
isinstance(1.3, int) # False
isinstance(True, bool) # True
isinstance([], list) # True
isinstance({}, dict) # True

有序集合类型

集合是指包含一组元素的数据结构,有序集合即集合里面的元素是是按照顺序排列的,Python 中的有序集合大概有以下几类:list, tuple, str, unicode。

list 类型

Python 中 List 类型类似于 JS 中的 Array,

L = [1, 2, 3]
print L[-1] # '3'

L.append(4) # 末尾添加元素
print L # [1, 2, 3, 4]

L.insert(0, 'hi') # 指定索引位置添加元素
print L # ['hi', 1, 2, 3, 4]

L.pop() # 末尾移除元素 L.pop(2) ?????? 2 ???
print L # ['hi', 1, 2, 3]

tuple 类型

tuple 类型是另一种有序的列表,中文翻译为“ 元组 ”。tuple 和 list 非常类似,但是,tuple 一旦创建完毕,就不能修改了。

t = (1, 2, 3)
print t[0] # 1
t[0] = 11 # TypeError: 'tuple' object does not support item assignment

t = (1)
print t # 1  t 的结果是整数 1

t = (1,) # 为了避免出现如上有歧义的单元素 tuple,所以 Python 规定,单元素 tuple 要多加一个逗号“,”
print t # (1,)

无序集合类型

dict 类型

Python 中的 dict 类型类似于 JS 中的 {} (最大的不同是它是没有顺序的), 它有如下特点:

  • 查找速度快 (无论 dict 有 10 个元素还是 10 万个元素,查找速度都一样)
  • 占用内存大 (与 list 类型相反)
  • dict 中的 key 不能重复
  • dict 中存储的 key-value 序对是没有顺序的
d = {
    'a': 1,
    'b': 2,
    'c': 3
}

print d # {'a': 1, 'c': 3, 'b': 2}  可以看出打印出的序对没有按正常的顺序打出

# 遍历 dict
for key,value in d.items():
    print('%s: %s' % (key,value))
# a: 1
# c: 3
# b: 2

set 类型

有的时候,我们只想要 dict 的 key,不关心 key 对应的 value,而且要保证这个集合的元素不会重复,这时,set 类型就派上用场了。set 类型有如下特点:

  • set 存储的元素和 dict 的 key 类似,必须是不变对象
  • set 存储的元素也是没有顺序的
s = set(['A', 'B', 'C', 'C'])
print s # set(['A', 'C', 'B'])

s.add('D')
print s # set(['A', 'C', 'B', 'D'])

s.remove('D')
print s # set(['A', 'C', 'B'])

Python 中的迭代

在介绍完 Python 中的有序集合和无序集合类型后,必然存在遍历集合的 for 循环。但是和其它语言的标准 for 循环不同,Python 中的所有迭代是通过 for ... in 来完成的。以下给出一些常用的迭代 demos:

索引迭代:

L = ['apple', 'banana', 'orange']
for index, name in enumerate(L):  # enumerate() 函数把 ['apple', 'banana', 'orange'] 变成了类似 [(0, 'apple), (1, 'banana'), (2, 'orange')] 的形式
    print index, '-', name

# 0 - apple
# 1 - banana
# 2 - orange

迭代 dict 的 value:

d = { 'apple': 6, 'banana': 8, 'orange': 5 }
print d.values() # [6, 8, 5]
for v in d.values()
    print v
# 6
# 8
# 5

迭代 dict 的 key 和 value:

d = { 'apple': 6, 'banana': 8, 'orange': 5 }
for key, value in d.items()
    print key, ':', value
# apple : 6
# banana: 8
# orange: 5

切片操作符

Python 提供的切片操作符类似于 JS 提供的原生函数 slice()。有了切片操作符,大大简化了一些原来得用循环的操作。

L = ['apple', 'banana', 'orange', 'pear']
L[0:2] # ['apple', 'banana'] 取前 2 个元素
L[:2] # ['apple', 'banana'] 如果第一个索引是 0,可以省略
L[:] # ['apple', 'banana', 'orange', 'pear'] 只用一个 : ,表示从头到尾
L[::2] # ['apple', 'orange'] 第三个参数表示每 N 个取一个,这里表示从头开始,每 2 个元素取出一个来

列表生成器

如果要生成 [1x1, 2x2, 3x3, ..., 10x10] 怎么做?方法一是循环:

L = []
for x in range(1, 11):
    L.append(x * x)

但是循环太繁琐,而列表生成式则可以用一行语句代替循环生成上面的 list:

# 把要生成的元素 x * x 放到前面,后面跟 for 循环,就可以把 list 创建出来
[x * x for x in range(1, 11)]
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

列表生成式的 for 循环后面还可以加上 if 判断(类似于 JS 中的 filter() 函数),示例如下:

[x * x for x in range(1, 11) if x % 2 == 0]
# [4, 16, 36, 64, 100]

for 循环可以嵌套,因此,在列表生成式中,也可以用多层 for 循环来生成列表。

[m + n for m in 'ABC' for n in '123']
# ['A1', 'A2', 'A3', 'B1', 'B2', 'B3', 'C1', 'C2', 'C3']

Python 函数

默认参数

JS 中 ES6 的 默认参数正是借鉴于 Python,用法如下:

def greet(name='World'):
    print 'Hello, ' + name + '.'

greet() # Hello, World.
greet('Python') # Hello, Python.

可变参数

类似于 JS 函数中自动识别传入参数的个数,Python 也提供了定义可变参数,即在可变参数的名字前面带上个 * 号。

def fn(*args):
    print args

fn()  # ()
fn('a') # ('a',)
fn('a', 'b') # ('a', 'b')

Python 解释器会把传入的一组参数组装成一个 tuple 传递给可变参数,因此,在函数内部,直接把变量 args 看成一个 tuple 就好了。

常用高阶函数

Python 中常用的函数 (map、reduce、filter) 的作用和 JS 中一致,只是用法稍微不同。

  • map 函数: 接收一个函数 f 和一个 list,并通过把函数 f 依次作用在 list 的每个元素上,得到一个新的 list 并返回。
def f(x):
    return x * x
print map(f, [1, 2, 3, 4, 5, 6, 7, 8, 9]) # [1, 4, 9, 16, 25, 36, 49, 64, 81]
  • reduce 函数: 接收一个函数 f 和一个 list(可以接受第三个值作为初始值),reduce() 对 list 的每个元素反复调用函数 f,并返回最终结果值。
def f(x, y):
    return x * y

reduce(f, [1, 3, 5]) # 15
  • filter 函数: 接收一个函数 f 和一个list,这个函数 f 的作用是对每个元素进行判断,返回 True或 False,filter() 根据判断结果自动过滤掉不符合条件的元素,返回由符合条件元素组成的新 list。
def is_odd(x):
    return x % 2 == 1

filter(is_odd, [1, 4, 6, 7, 9, 12, 17]) # [1, 7, 9, 17]

匿名函数

和 JS 的匿名函数不同的地方是,Python 的匿名函数中只能有一个表达式,且不能写 return。拿 map() 函数为例:

map(lambda x: x * x, [1, 2, 3, 4, 5, 6, 7, 8, 9]) # [1, 4, 9, 16, 25, 36, 49, 64, 81]

关键词 lambda 表示匿名函数,冒号前面的 x 表示函数参数,可以看出匿名函数 lambda x: x* x 实际上就是:

def f(x):
    return x * x

闭包

之前写过一些关于 JS 闭包的文章,比如 深入浅出JavaScript之闭包(Closure)、以及 读书笔记-你不知道的 JavaScript (上),Python 中闭包的定义和 JS 中的是一致的即:内层函数引用了外层函数的变量,然后返回内层函数。下面来看下 Py 中闭包之 for 循环经典问题:

# 希望一次返回3个函数,分别计算1x1,2x2,3x3:
def count():
    fs = []
    for i in range(1, 4):
        def f():
            return i * i
        fs.append(f)
    return fs

f1, f2, f3 = count() # 这种写法相当于 ES6 中的解构赋值
print f1(), f2(), f3() # 9 9 9

老问题了,f1(), f2(), f3() 结果不应该是 1, 4, 9 吗,实际结果为什么都是 9 呢?

原因就是当 count() 函数返回了 3 个函数时,这 3 个函数所引用的变量 i 的值已经变成了 3。由于 f1、f2、f3 并没有被调用,所以,此时他们并未计算 i*i,当 f1 被调用时,i 已经变为 3 了。

要正确使用闭包,就要确保引用的局部变量在函数返回后不能变。代码修改如下:

方法一: 可以理解为创建了一个封闭的作用域,i 的 值传给 j 之后,就和 i 没任何关系了。每次循环形成的闭包都存进了内存中。

def count():
    fs = []
    for i in range(1, 4):
        def f(j):
            def g(): # 方法一
                return j * j
            return g
        r = f(i)
        fs.append(r)
    return fs

f1, f2, f3 = count()
print f1(), f2(), f3() # 1 4 9

方法二:思路比较巧妙,用到了默认参数 j 在函数定义时可以获取到 i 的值,虽然没有用到闭包,但是和方法一有异曲同工之处。

def count():
    fs = []
    for i in range(1, 4):
        def f(j = i): # 方法二
            return j * j
        fs.append(f)
    return fs

f1, f2, f3 = count()
print f1(), f2(), f3() # 1 4 9

decorator 装饰器

ES6 的语法中的 decorator 正是借鉴了 �Python 的 decorator。decorator 本质上就是一个高阶函数,它接收一个函数作为参数,然后返回一个新函数

那装饰器的作用在哪呢?先上一段日常项目中用 ts 写的网关代码:

@Post('/rider/detail')  // URL 路由
@log()                   // 打印日志
  @ResponseBody
  public async getRiderBasicInfo(
    @RequestBody('riderId') riderId: number,
    @RequestBody('cityId') cityId: number,
  ) {
    const result = await this.riderManager.findDetail(cityId, riderId)
    return result
  }

可以看出使用装饰器可以极大地简化代码,避免每个函数(比如日志、路由、性能检测)编写重复性代码。

回到 Python 上,Python 提供的 @ 语法来使用 decorator,@ 等价于 f = decorate(f)。下面来看看 @log() 在 Python 中的实现:

# 我们想把调用的函数名字给打印出来
@log()
def factorial(n):
    return reduce(lambda x,y: x*y, range(1, n+1))
print factorial(10)

# 来看看 @log() 的定义
def log():
    def log_decorator(f):
        def fn(x):
            print '调用了函数' + f.__name__ + '()'
            return f(x)
        return fn
    return log_decorator

# 结果
# 调用了函数 factorial()
# 3628800

class

面向对象编程

面向对象编程是一种程序设计范式,基本**是:用类定义抽象类型,然后根据类的定义创建出实例。在掌握其它语言的基础上,还是比较容易理解这块知识点的,比如从下面两种写法可以看出不同语言的语言特性间竟然有如此多的共性。

es6: (附:本文的主题是 python,所以只是初略展示下 js 中类的定义以及实例的创建,为了说明写法的相似性)

class Person {
    constructor(name, age) {
        this.name = name
        this.age = age
    }
}

const child1 = new Person('Xiao Ming', 10)

Python: (核心要点写在注释中)

# 定义一个 Person 类:根据 Person 类就可以造成很多 child 实例
class Person(object):
    address = 'Earth' # 类属性 (实例公有)
    def __init__(self, name, age): # 创建实例时,__init__()方法被自动调用
        self.name = name
        self.age = age
    def get_age(self): # 定义实例方法,它的第一个参数永远是 self,指向调用该方法的实例本身,其他参数和普通函数是一样的
        return self.age

child1 = Person('Xiao Ming', 10)
child2 = Person('Xiao Hong', 9)

print child1.name # 'Xiao Ming'
print child2.get_age() # 9
print child1.address # 'Earth'
print child2.address # 'Earth'

继承

child 属于 Student 类,Student 类属于 People 类,这就引出了继承: 即获得了父类的方法属性后又能添加自己的方法属性。

class Person(object):
    def __init__(self, name, age):
        self.name = name
        self.age = age

class Student(Person):
    def __init__(self, name, age, grade):
        super(Student, self).__init__(name, age) # 这里也能写成 Person.__init__(self, name, age)
        self.grade = grade

s = Student('Xiao Ming', 10, 90)
print s.name # 'Xiao Ming'
print s.grade # 90

可以看到子类在父类的基础上又增加了 grade 属性。我们可以再来看看 s 的类型。

isinstance(s, Person)
isinstance(s, Student)

可以看出,Python 中在一条继承链上,一个实例可以看成它本身的类型,也可以看成它父类的类型。

some knowledge about linux

文件操作

head

显示文件的头部内容,如果不指定参数默认显示 10 行

# 显示前 10 行内容
head README.md

# 或者显示多个文件
head README.md package.json

# -n 指定显示行数
head -n 100 README.md

tail

显示文件的末尾部分

# 默认显示末尾10行
tail README.md

# -n 指定显示末尾20行
tail -n 20 README.md

ls

# 显示当前目录列表
ls

# 显示目录列表的详细信息
ls -l

# 显示指定目录
ls ./src

# 可读性地显示目录列表详细信息, h means human-readable
ls -lh

# 列出所有文件包括隐藏文件
ls -a

# -F 可以显示类型,用以区分是文件还是目录
ls -F # 后缀为 ”/“ 代表是目录,”*“ 为可执行文件,没有则为文件

# -i 查看 inode 编号, 每一个文件或目录都有一个唯一的编号,这个数字由内核分配给文件系统中的每一个对象
ls -i

# 过滤文件列表, * 代表 0 个或多个字符, ? 代表一个字符
ls READ*

pwd

显示当前路径

# pwd means pathname of the current working directory
pwd

wc

统计文件的行数、字数、字节数, 常见用于统计代码行数

# 统计行数
wc -l README.md

# 统计字数
wc -w README.md

# 统计字符数, 中文占两个字符, 英文占一个字符
wc -m README.md

find

指定某个目录下查找文件

# 在当前目录递归搜索文件名为 README.md 文件
find . -name README.md

# 通过通配符进行查找, 必须用引号括着, 这里查找所有后缀为 .md 文件
find . -name "*.md"
find . -iname "*.md"  # 忽略文件大小写

# 排除文件,只要加 ! , 排除掉所有 .md 后缀的文件
find . ! -name "*.md"

# 根据类型进行过滤搜索
# f 普通文件 d 目录
find . -type f

# 限定目录递归深度
find . -maxdepth 3  # 最大为3个目录
find . -mindepth 3  # 最小为3个目录

# 查找文件大小大于 25k 文件
find . -size +25k

# 查找 10 天前文件 -mtime 修改时间、 -ctime 创建时间、 -atime 访问时间
find . -mtime +10

mkdir

# 在当前目录下创建 temp 目录
mkdir temp

# 创建多层目录, p means parent
mkdir -p temp/temp2/temp3

# 基于权限创建, m means mode
mkdir -m 777 temp

more

  • 空格 - 查看下一屏内容
  • B - 查看上一屏内容
  • 回车 - 查看下一行内容
  • Q - 退出
more README.md

# 从第 10 行开始显示
more +10 README.md

paste

合并 N 个文件的

并不是纵向合并, 而是横向合并。

# 1.txt 和 2.txt 合并输出
paste 1.txt 2.txt

# 1.txt 2.txt 合并后保存为 3.txt
paste 1.txt 2.txt > 3.txt

stat

用于显示文件或目录的状态信息

16777220 8702541224 -rw-r--r-- 1 mac staff 0 15857 "Nov  1 13:02:02 2020" "Nov  1 13:02:02 2020" "Nov  1 13:02:02 2020" "Oct 25 18:48:15 2020" 4096 32 0 README.md

grep

强大的文本搜索工具, 被称为 Linux 命令三剑客。

# 从 README.md 文件中搜索 linux 关键字
grep "linux" README.md
grep "linux" README.md README2.md # 多个文件搜索

# 输出时高亮显示
grep "linux" README.md --color

# -o 只输出匹配部分
grep -o "linux" README.md --color

# -n 输出到匹配的行数
grep -n "linux" README.md

# -c 输出到匹配次数
grep -c "linux" README.md

# -r 递归目录文件搜索
grep -r "linux" ./src

# 使用 glob 风格表达式搜索
egrep "[0-9]" # 等价于 grep -E "[0-9]" README.md

touch

创建一个空文件, 如果文件存在只会修改文件的创建时间

touch README.md

cd

进入指定目录

# 进入当前 src 目录
cd src

# 回到上一次目录
cd -

# 返回上一级目录
cd ..
cd ../../..   # 返回多级

# 进入家目录
cd ~
cd # 或者不带任何参数

# 将上一个命令的参数作为cd参数使用
cd !$

# 模糊匹配目录,有时目录名很长一个一个敲效率就很低
# * 代表0个或多个字符, ? 代表一个字符
cd READ*

rm

删除指定目录或文件

使用此命令需要非常小心, 一但删除无法恢复

# 删除当前 1.txt 文件
rm 1.txt

# -i 删除前询问是否真的要删除,因为一旦删除无法恢复
rm -i README.md

# 这条命令比较常用, 强制删除目录或文件
# -r 如果是目录递归删除, -f 强制删除 不发出任何警告
rm -rf ./src

cp

拷贝文件或目录

# 将当前 README.md 文件拷贝到上一层
cp ./README.md ../README.md

# -a 将原文件属性一同拷贝, 修改时间、创建时间等
cp -a ./README.md ../README.md

# -r 用于递归拷贝目录
cp -r home ../home

# -i 如果目标文件存在会询问用户是否需要覆盖
cp -i README.md README.md

cat

查看指定整个文件内容

# 查看 README.md 文件所有内容
cat README.md
cat README.md README2.md  # 或者一次性显示多个文件

# -n 每一行显示行号
cat -n README.md

# -b 只给有内容的行显示行号
cat -b README.md

mv

mv 有 2 个用途:

  • 将文件或目录移动到另一个位置
  • 将文件或目录重命名
# 将 README.md 重命名为 README-2.md, 如果 README-2.md 存在会直接覆盖。
mv README.md README-2.md

# 将 README.md 移动到上一层目录
mv README.md ../README.md

# -i 交互式操作,如果目标文件存在则进行询问是否覆盖
mv -i README.md ../README.md

open

open 命令可在 linux / mac 具有可视化界面下进行文本编辑、打开应用程序等功能。

# 在mac下用Finder打开当前目录
open .

# 用默认应用程序打开文件
open README.md

# 用默认编辑器打开文件
open -e README.md

# 如果是一个URL用默认浏览器打开页面
open https://github.com/MuYunyun/blog.git

# 指定某个应用程序打开某个文件, 如果不指定文件默认直接打开程序
open -a /Applications/Google\ Chrome.app README.md

source

在当前Shell环境中从指定文件读取和执行命令, 通常用于重新执行环境。

它有个别名 . 点操作符号。

# 等价 . ~/.bash_profile
source ~/.bash_profile

实际上大部分开发者都没搞懂 source 命令。 可以把它理解为编程语言中的 import, java/python/js 都有这个,就是用来导入文件。

下面演示 source 用于 shell 脚本中

util.sh

#!/bin/bash
getName() {
  echo "Linux"
}

main.sh

#!/bin/bash
# 加载文件
source ./util.sh

# 这样就可以调用 util 文件中的函数了
echo $(getName)

tree

生成目录树结构, 通常用于描述项目结构。

# 递归当前目录下所有文件并生成目录树
tree

# -I 忽略某些目录
tree -I "node_modules"

# 只显示目录
tree -d

# 指定要递归的目录层级
tree -L 3

ln

将某一个文件在另外一个位置建立并产生同步的链接。当不同的 2 个目录需要同时引用某一个文件时此命令就派上用场了。

这个命令的应用场景: 比如 yarn link

  • 软链接也可以叫符号链接:
    • 软链接,以路径的形式存在。类似于Windows操作系统中的快捷方式;
    • 软链接可以 跨文件系统, 硬链接不可以;
    • 软链接可以对一个不存在的文件名进行链接;
    • 软链接可以对目录进行链接;
  • 硬链接:
    • 硬链接,以文件副本的形式存在。但不占用实际空间, 从根本上而言就是同一个文件;
    • 不允许给目录创建硬链接;
    • 硬链接只有在同一个文件系统中才能创建;

理解: 文件系统博主理解: 比如在 mac 系统中装了个虚拟机, 虚拟机里面跑了另外一个 window 系统, 那此时 mac 和虚拟机里面的系统就是两个不同的文件系统。

# 默认创建硬链接,修改 README.md 内容, a.md 也会同步修改, 修改 a.md, README.md 也会同步修改
ln README.md a.md

# -s 创建软链接
ln -s README.md a.md # 如果删除了 README.md  a.md 将失效

# -f 强制执行
ln -f README.md ./src/a.md

file

查看文件类型, 比如文件、目录、二进制、符号链接等。

file README.md
README.md: HTML document text, UTF-8 Unicode text

系统管理

ping

测试目标地址是否可连接、延迟度

# 测试 github.com 连通性, 按 ctrl + C 停止
ping github.com

# ping 5 次后断开
ping -c 5 github.com

# 每 5 秒 ping 一次
ping -i 5 github.com

which

查找某个命令存储在哪个位置, 输出绝对路径, which 会在环境变量 $PATH 设置的目录里去查找。

注: 可以通过 echo $PATH 查看设置的目录.

which top  # /usr/bin/top

# 打印多个命令
which ping top

uptime

# 当前系统运行的天数,小时,分钟 (从上次开机起计算), 当前系统登录用户数。一分钟、5分钟、15分钟平均负载, 这 3 个值不能大于 CPU 个数,如果大于了说明系统负载高,性能低。
uptime # 13:25  up 2 days, 18:57, 7 users, load averages: 2.06 2.06 2.15

uname

打印系统信息

# 不带任何参数打印当前操作系统内核名称
uname # Darwin , 等价于 uname -s

# 打印系统所有信息, cloud-2.local: 网络节点主机名称, x86_64: 主机的硬件架构名称
uname -a # Darwin cloud-2.local 19.4.0 Darwin Kernel Version 19.4.0: Wed Mar  4 22:28:40 PST 2020; root:xnu-6153.101.6~15/RELEASE_X86_64 x86_64

# 打印主机的硬件架构名称
uname -m # x86_64

ifconfig

配置或显示系统网卡的网络参数。

# 显示所有网络参数信息
ifconfig

lsof

列出当前系统打开文件的工具

## 打印所有打开文件的的列表
lsof

# 查看指定端口被占用情况
lsof -i:8080

alias

设置命令别名,用于简化较长的命令。

# 列出所有已设置的别名
alias

# 删除所有别名
unalias -a

# 设置别名
alias ll='ls -l'

系统进程

ps

ps 命令涵盖命令参数三大风格

  • Unix 风格, 前面加单破折线
  • BSD 风格, 全称伯克利软件发行版(Berkeley software distribution), 前面不加破折线
  • GNU 风格, 全称 "Gnu's Not Unix", 前面加双破折线
# 配合 grep 查询指定进程, -a means: all, -f means full
ps -af | grep nginx

其它

clear

用于清除当前终端所有信息,本质上只是向后翻了一页,往上滚动还能看到之前的操作信息

注:效果等用于 command + K 可以完全清除终端所有操作信息。

clear

link

project plan

RFC

  • to build visualization site to show every .md file.
    • create-react-doc start;
    • create-react-doc build;
    • create-react-doc upload;
    • lint;

For example, the root has React dir, the site should has a menu name called React.

HOW TO USE

yarn add seed

// perf
seed start

And then you'll see the blog run in the localhost: 3000.

JS 装饰器解析

随着 ES6 和 TypeScript 中类的引入,在某些场景需要在不改变原有类和类属性的基础上扩展些功能,这也是装饰器出现的原因。

装饰器简介

作为一种可以动态增删功能模块的模式(比如 redux 的中间件机制),装饰器同样具有很强的动态灵活性,只需在类或类属性之前加上 @方法名 就完成了相应的类或类方法功能的变化。

不过装饰器模式仍处于第 2 阶段提案中,使用它之前需要使用 babel 模块 transform-decorators-legacy 编译成 ES5 或 ES6。

在 TypeScript 的 lib.es5.d.ts 中,定义了 4 种不同装饰器的接口,其中装饰类以及装饰类方法的接口定义如下所示:

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

下面对这两种情况进行解析。

作用于类的装饰器

当装饰的对象是类时,我们操作的就是这个类本身

@log
class MyClass { }

function log(target) { // 这个 target 在这里就是 MyClass 这个类
   target.prototype.logger = () => `${target.name} 被调用`
}

const test = new MyClass()
test.logger() // MyClass 被调用

由于装饰器是表达式,我们也可以在装饰器后面再添加提个参数:

@log('hi')
class MyClass { }

function log(text) {
  return function(target) {
    target.prototype.logger = () => `${text}${target.name} 被调用`
  }
}

const test = new MyClass()
test.logger() // hello,MyClass 被调用

在使用 redux 中,我们最常使用 react-redux 的写法如下:

@connect(mapStateToProps, mapDispatchToProps)
export default class MyComponent extends React.Component {}

经过上述分析,我们知道了上述写法等价于下面这种写法:

class MyComponent extends React.Component {}
export default connect(mapStateToProps, mapDispatchToProps)(MyComponent)

作用于类方法的装饰器

与装饰类不同,对类方法的装饰本质是操作其描述符。可以把此时的装饰器理解成是 Object.defineProperty(obj, prop, descriptor) 的语法糖,看如下代码:

class C {
  @readonly(false)
  method() { console.log('cat') }
}

function readonly(value) {
  return function (target, key, descriptor) { // 此处 target 为 C.prototype; key 为 method;
    // 原 descriptor 为:{ value: f, enumarable: false, writable: true, configurable: true }
    descriptor.writable = value
    return descriptor
  }
}

const c = new C()
c.method = () => console.log('dog')

c.method() // cat

可以看到装饰器函数接收的三个参数与 Object.defineProperty 是完全一样的,具体实现可以看 babel 转化后的代码,主要实现如下所示:

var C = (function() {
  class C {
    method() { console.log('cat') }
  }

  var temp
  temp = readonly(false)(C.prototype, 'method',
    temp = Object.getOwnPropertyDescriptor(C.prototype, 'method')) || temp // 通过 Object.getOwnPropertyDescriptor 获取到描述符传入到装饰器函数中

  if (temp) Object.defineProperty(C.prototype, 'method', temp)
  return C
})()

再将再来看看如果有多个装饰器作用于同一个方法上呢?

class C {
  @readonly(false)
  @log
  method() { }
}

经 babel 转化后的代码如下:

desc = [readonly(false), log]
    .slice()
    .reverse()
    .reduce(function(desc, decorator) {
      return decorator(target, property, desc) || desc;
    }, desc);

可以清晰地看出,经过 reverse 倒序后,装饰器方法会至里向外执行。

相关链接

javascript-decorators
Javascript 中的装饰器
JS 装饰器(Decorator)场景实战
修饰器
Babel

2.组件和 state|props 的实现

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

组件即函数

在上一篇 JSX 和 Virtual DOM 中,解释了 JSX 渲染到界面的过程并实现了相应代码,代码调用如下所示:

import React from 'react'
import ReactDOM from 'react-dom'

const element = (
  <div className="title">
    hello<span className="content">world!</span>
  </div>
)

ReactDOM.render(
  element,
  document.getElementById('root')
)

本小节,我们接着探究组件渲染到界面的过程。在此我们引入组件的概念,组件本质上就是一个函数,如下就是一段标准组件代码:

import React from 'react'

// 写法 1:
class A {
  render() {
    return <div>I'm componentA</div>
  }
}

// 写法 2:无状态组件
const A = () => <div>I'm componentA</div>

ReactDOM.render(<A />, document.body)

<A name="componentA" /> 是 JSX 的写法,和上一篇同理,babel 将其转化为 React.createElement() 的形式,转化结果如下所示:

React.createElement(A, null)

可以看到当 JSX 中是自定义组件的时候,createElement 后接的第一个参数变为了函数,在 repl 打印 <A name="componentA" />,结果如下:

{
  attributes: undefined,
  children: [],
  key: undefined,
  nodeName: ƒ A()
}

注意这时返回的 Virtual DOM 中的 nodeName 也变为了函数。根据这些线索,我们对之前的 render 函数进行改造。

function render(vdom, container) {
  if (_.isFunction(vdom.nodeName)) { // 如果 JSX 中是自定义组件
    let component, returnVdom
    if (vdom.nodeName.prototype.render) {
      component = new vdom.nodeName()
      returnVdom = component.render()
    } else {
      returnVdom = vdom.nodeName() // 针对无状态组件:const A = () => <div>I'm componentsA</div>
    }
    render(returnVdom, container)
    return
  }
}

至此,我们完成了对组件的处理逻辑。

props 和 state 的实现

在上个小节组件 A 中,是没有引入任何属性和状态的,我们希望组件间能进行属性的传递(props)以及组件内能进行状态的记录(state)。

import React, { Component } from 'react'

class A extends Component {
  render() {
    return <div>I'm {this.props.name}</div>
  }
}

ReactDOM.render(<A name="componentA" />, document.body)

在上面这段代码中,看到 A 函数继承自 Component。我们来构造这个父类 Component,并在其添加 state、props、setState 等属性方法,从而让子类继承到它们。

function Component(props) {
  this.props = props
  this.state = this.state || {}
}

首先,我们将组件外的 props 传进组件内,修改 render 函数中以下代码:

function render(vdom, container) {
  if (_.isFunction(vdom.nodeName)) {
    let component, returnVdom
    if (vdom.nodeName.prototype.render) {
      component = new vdom.nodeName(vdom.attributes) // 将组件外的 props 传进组件内
      returnVdom = component.render()
    } else {
      returnVdom = vdom.nodeName(vdom.attributes) 	// 处理无状态组件:const A = (props) => <div>I'm {props.name}</div>
    }
    ...
  }
  ...
}

实现完组件间 props 的传递后,再来聊聊 state,在 react 中是通过 setState 来完成组件状态的改变的,在后面的 setState 优化 中会实现 setState 的异步逻辑,此处简单实现如下:

function Component(props) {
  this.props = props
  this.state = this.state || {}
}

Component.prototype.setState = function() {
  this.state = Object.assign({}, this.state, updateObj) // 这里简单实现,后续篇章会深入探究
  const returnVdom = this.render() // 重新渲染
  document.getElementById('root').innerHTML = null
  render(returnVdom, document.getElementById('root'))
}

此时虽然已经实现了 setState 的功能,但是 document.getElementById('root') 节点写死在 setState 中显然不是我们希望的,我们将 dom 节点相关转移到 _render 函数中:

Component.prototype.setState = function(updateObj) {
  this.state = Object.assign({}, this.state, updateObj)
  _render(this) // 重新渲染
}

自然地,重构与之相关的 render 函数:

function render(vdom, container) {
  let component
  if (_.isFunction(vdom.nodeName)) {
    if (vdom.nodeName.prototype.render) {
      component = new vdom.nodeName(vdom.attributes)
    } else {
      component = vdom.nodeName(vdom.attributes) // 处理无状态组件:const A = (props) => <div>I'm {props.name}</div>
    }
  }
  component ? _render(component, container) : _render(vdom, container)
}

在 render 函数中分离出 _render 函数的目的是为了让 setState 函数中也能调用 _render 逻辑。完整 _render 函数如下:

function _render(component, container) {
  const vdom = component.render ? component.render() : component
  if (_.isString(vdom) || _.isNumber(vdom)) {
    container.innerText = container.innerText + vdom
    return
  }
  const dom = document.createElement(vdom.nodeName)
  for (let attr in vdom.attributes) {
    setAttribute(dom, attr, vdom.attributes[attr])
  }
  vdom.children.forEach(vdomChild => render(vdomChild, dom))
  if (component.container) {  // 注意:调用 setState 方法时是进入这段逻辑,从而实现我们将 dom 的逻辑与 setState 函数分离的目标;知识点: new 出来的同一个实例
    component.container.innerHTML = null
    component.container.appendChild(dom)
    return
  }
  component.container = container
  container.appendChild(dom)
}

让我们用下面这个用例跑下写好的 react 吧!

class A extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 1
    }
  }

  click() {
    this.setState({
      count: ++this.state.count
    })
  }

  render() {
    return (
      <div>
        <button onClick={this.click.bind(this)}>Click Me!</button>
        <div>{this.props.name}:{this.state.count}</div>
      </div>
    )
  }
}

ReactDOM.render(
  <A name="count" />,
  document.getElementById('root')
)

效果图如下:

至此,我们实现了 props 和 state 部分的逻辑。

forceUpdate 的实现

声明:这部分为补充章节,可以选择性阅读。涉及到后文生命周期setState 章节的知识点。

当没有使用 setState 更新 state 状态时,通常要结合 forceUpdate 一起使用,例子如下:

class B extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: {
        value: 1
      }
    }
  }

  shouldComponentUpdate() { // 当使用 forceUpdate() 时,shouldComponentUpdate() 会失效
    return false
  }

  click() {
    this.state.count.value = ++this.state.count.value // 没有使用 setState 更新 state 状态时,通常要结合 forceUpdate 一起使用
    this.forceUpdate()
  }

  render() {
    return (
      <div>
        <button onClick={this.click.bind(this)}>Click Me!</button>
        <div>{this.state.count.value}</div>
      </div>
    )
  }
}

这里要注意一个点当使用 forceUpdate() 时,shouldComponentUpdate() 会失效,下面我们来补充 forceUpdate() 的代码逻辑:

// force to update
Component.prototype.forceUpdate = function(cb) {
  this.allowShouldComponentUpdate = false // 不允许 allowShouldComponentUpdate 执行
  asyncRender({}, this, cb)
}

相应的在 render.js 中加上 allowShouldComponentUpdate 的判断条件:

function renderComponent(component) {
  if (component.base && component.shouldComponentUpdate && component.allowShouldComponentUpdate !== false) { // 加上 allowShouldComponentUpdate 的判断条件
    const bool = component.shouldComponentUpdate(component.props, component.state)
    if (!bool && bool !== undefined) {
      return false // shouldComponentUpdate() 返回 false,则生命周期终止
    }
  }
  ...
}

小结

组件即函数;当 JSX 中是自定义组件时,经过 babel 转化后的 React.createElement(fn, ..) 后中的第一个参数变为了函数,除此之外其它逻辑与 JSX 中为 html 元素的时候相同;

此外我们将 state/props/setState 等 api 封装进了父类 React.Component 中,从而在子类中能调用这些属性和方法。

在下篇,我们会继续实现生命周期机制。

鸣谢

Especially thank simple-react for the guidance function of this library. At the meantime,respect for preact and react

1.JSX 和 Virtual DOM

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

环境准备

首先安装以下 babel 模块,其具体作用会在后文 JSX 和 虚拟 DOM 中提及

扩展延伸:babel 执行机制

"@babel/core": "^7.0.0",
"@babel/preset-env": "^7.0.0",
"@babel/preset-react": "^7.0.0",
"babel-loader": "v8.0.0-beta.0",

同时 .babelrc 配置如下:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": "> 0.25%, not dead",
        "useBuiltIns": "entry"
      }
    ],
    [
      "@babel/preset-react",
      {
        "pragma": "cpreact.createElement" // 该参数传向 transform-react-jsx 插件,是前置的一个核心,后文有解释为什么使用 cpreact.createElement
      }
    ]
  ]
}

配置好 babel 后,接着提供两套打包工具的配置方案,读者可以自行选择。

方案 1:使用 webpack

webpack 拥有一个活跃的社区,提供了更为丰富的打包能力。

首先安装以下模块:

"webpack": "^4.17.2",
"webpack-cli": "^3.1.0",
"webpack-dev-server": "^3.1.8"

在根目录的 webpack.config.js 配置如下:

const webpack = require('webpack')
const path = require('path')
const rootPath = path.resolve(__dirname)

module.exports = {
  entry: path.resolve(rootPath, 'test', 'index.js'),
  mode: 'development',
  devtool: 'inline-source-map',
  devServer: {
    contentBase: './dist'
  },
  output: {
    filename: 'cpreact.js',
    path: path.resolve(rootPath, 'dist'),
    libraryTarget: 'umd'
  },
  module: {
    rules: [{
      test: /\.js$/,
      loader: "babel-loader",
    }]
  },
}

然后在 package.json 里加上如下配置:

"scripts": {
  "start": "webpack-dev-server --open",
},

具体可以参照 0.4.3 版本

方案 2:使用 parcel

parcel 是一款上手极快的打包工具,使用其可以快速地进入项目开发的状态。在 package.json 加上如下配置,具体可以参照 0.1 版本

"scripts": {
  "start": "parcel ./index.html --open -p 8080 --no-cache"
},

JSX 和 虚拟 DOM

const element = (
  <div className="title">
    hello<span className="content">world!</span>
  </div>
)

JSX 是一种语法糖,经过 babel 转换结果如下,可以发现实际上转化成 React.createElement() 的形式:

扩展:babel执行机制

var element = React.createElement(
  "div",
  { className: "title" },
  "hello",
  React.createElement(
    "span",
    { className: "content" },
    "world!"
  )
);

打印 element, 结果如下:

{
  attributes: {className: "title"}
  children: ["hello", t] // t 和外层对象相同
  key: undefined
  nodeName: "div"
}

因此,我们得出结论:JSX 语法糖经过 Babel 编译后转换成一种对象,该对象即所谓的虚拟 DOM,使用虚拟 DOM 能让页面进行更为高效的渲染。

我们按照这种思路进行函数的构造:

const React = {
  createElement
}

function createElement(tag, attr, ...child) {
  return {
    attributes: attr,
    children: child,
    key: undefined,
    nodeName: tag,
  }
}

// 测试
const element = (
  <div className="title">
    hello<span className="content">world!</span>
  </div>
)

console.log(element) // 打印结果符合预期
// {
//   attributes: {className: "title"}
//   children: ["hello", t] // t 和外层对象相同
//   key: undefined
//   nodeName: "div"
// }

虚拟 DOM 转化为真实 DOM

上个小节介绍了 JSX 转化为虚拟 DOM 的过程,这个小节接着来实现将虚拟 DOM 转化为真实 DOM (页面上渲染的是真实 DOM)。

我们知道在 React 中,将虚拟 DOM 转化为真实 DOM 是使用 ReactDOM.render 实现的,使用如下:

import ReactDOM from 'react-dom'

ReactDOM.render(
  element, // 上文的 element,即虚拟 dom
  document.getElementById('root')
)

接着来实现 ReactDOM.render 的逻辑:

const ReactDOM = {
  render
}

/**
 * 将虚拟 DOM 转化为真实 DOM
 * @param {*} vdom      虚拟 DOM
 * @param {*} container 需要插入的位置
 */
function render(vdom, container) {
  if (typeof(vdom) === 'string') {
    container.innerText = vdom
    return
  }
  const dom = document.createElement(vdom.nodeName)
  for (let attr in vdom.attributes) {
    setAttribute(dom, attr, vdom.attributes[attr])
  }
  vdom.children.forEach(vdomChild => render(vdomChild, dom))
  container.appendChild(dom)
}

/**
 * 给节点设置属性
 * @param {*} dom   操作元素
 * @param {*} attr  操作元素属性
 * @param {*} value 操作元素值
 */
function setAttribute(dom, attr, value) {
  if (attr === 'className') {
    attr = 'class'
  }
  if (attr.match(/on\w+/)) {   // 处理事件的属性:
    const eventName = attr.toLowerCase().splice(1)
    dom.addEventListener(eventName, value)
  } else if (attr === 'style') { // 处理样式的属性:
    let styleStr = ''
    let standardCss
    for (let klass in value) {
      standardCss = humpToStandard(klass) // 处理驼峰样式为标准样式
      value[klass] = _.isNumber(+value[klass]) ? value[klass] + 'px' : value[klass] // style={{ className: '20' || '20px' }}>
      styleStr += `${standardCss}: ${value[klass]};`
    }
    dom.setAttribute(attr, styleStr)
  } else {                       // 其它属性
    dom.setAttribute(attr, value)
  }
}

至此,我们成功将虚拟 DOM 复原为真实 DOM,展示如下:

另外配合热更新,在热更新的时候清空之前的 dom 元素,改动如下:

const ReactDOM = {
  render(vdom, container) {
    container.innerHTML = null
    render(vdom, container)
  }
}

总结

JSX 经过 babel 编译为 React.createElement() 的形式,其返回结果就是 Virtual DOM,最后通过 ReactDOM.render() 将 Virtual DOM 转化为真实的 DOM 展现在界面上。流程图如下:

思考题

如下是一个 react/preact 的常用组件的写法,那么为什么要 import 一个 React 或者 h 呢?

import React, { Component } from 'react' // react
// import { h, Component } from 'preact' // preact

class A extends Component {
  render() {
    return <div>I'm componentA</div>
  }
}

render(<A />, document.body) // 组件的挂载

项目说明

该系列文章会尽可能的分析项目细节,具体的还是以项目实际代码为准。

鸣谢

Especially thank simple-react for the guidance function of this library. At the meantime,respect for preact and react

生成器(Generator)札记

Generator 使用相关

从一道简单的题目开始:

function* gen() {
  const a = yield 1
  console.log(a)
}

为了让其能成功打印出 1,设计如下函数:

function step(gen) {
  const it = gen()
  let result
  return function() {
    result = it.next(result).value
  }
}

可进行如下调用

var a = step(gen)
a()
a() // 1

从这个题目总结下规律

  • next 的调用数比 yield 的调用数多 1;
  • 第一个 next 传值无效,从第二个 next 开始的传值有效并会覆盖掉 yield 的传值;

生成器中的 yield/next 除了控制能力外还有双向的消息通知能力:

  • yield .. 后跟的值能通过 it.next().value 取到
  • it.next(..) 中 next 里的值又能作为 yield .. 的值返回

yield 暂停的位置

function* foo(url) {
  try {
    const val = yield request(url)
    console.log(val)
  } catch (err) {
    ...
  }
}

const it = foo('http://some.url.1')

yield 后面跟着的语句执行完再进入暂停状态的,在如上代码中,当执行 it.next() 时,可以稍加转换为如下形式:

function* foo(url) {
  try {
    const promise = request(url) // 当执行 it.next() 时,这里是被执行的
    const val = yield promise // 这里被暂停
    console.log(val)
  } catch (err) {
    ...
  }
}

遇到 return,throw

  • 遇到 return
function* gen() {
  yield 1
  return 2
  console.log('执行/不执行')
}

const it = gen()
it.next() // {value: 1, done: false}
it.next() // {value: 2, done: true}
it.next() // {value: undefined, done: true}

总结:遇到 return,generator 函数结束中断,done 变为 true;

  • 遇到 iterator 的 throw
function* gen() {
  yield 1
  console.log('执行/不执行')
}

var it = gen()
it.throw(new Error('boom')) // Error: boom
it.next()                   // {value: undefined, done: true}

总结:遇到 iterator 的 throw,generator 函数运行中断,done 变为 true;

翻译下 generator

Generator 是一个返回迭代器的函数,日后可以研究下 regenerator,目前简单食用如下:

function foo(url) {
  var state
  var val
  function process(v) {
    switch (state) {
      case 1:
        console.log('requesting:', url)
        return request(url)
      case 2:
        val = v
        console.log(val)
        return
      case 3:
        var err = val
        console.log('Oops:', err)
        return false
    }
  }
  return {
    next: function(v) {
      if (!state) {
        state = 1
        return {
          done: false,
          value: process()
        }
      } else if (state === 1) {
        state = 2
        return {
          done: true,
          value: process(v)
        }
      } else {
        return {
          done: true,
          value: undefined
        }
      }
    },
    throw: function() {
      if (state === 1) {
        state = 3
        return {
          done: true,
          value: process(e)
        }
      } else {
        throw e
      }
    }
  }
}

var it = foo('http://some.url.1')

Generator 函数的异步应用

以 co 库来说,现在已经统一为 Generator + Promise 的调用方式,下面进行简单的模拟:

co(function* () {
  const result = yield Promise.resolve(true)
  console.log(result) // true
})
// 简版 promise
function co(gen) {
  const it = gen()
  const step = function(data) {
    const result = it.next(data)
    if (result.done) {
      return result.value
    }
    result.value.then((data) => {
      step(data)
    })
  }
  step()
}

观察 co 库发现,co 函数后返回的是 promise,使用如下:

// 期待
co(function* () {
  const result = yield Promise.resolve(true)
  return result // 这里有个语法,it.next() 碰到 return 后,其值会变为 { value: result, done: true } 的形式
}).then((data) => {
  console.log(data) // true
})

我们再对其稍加改造,使之更加添近 co 库:

function co(gen) {
  return new Promise((resolve, reject) => {
    const it = gen()
    let result
    const step = function(fn) {
      try {
        result = fn()
      } catch(e) {
        return reject(e)
      }
      if (result.done) { return resolve(result.value) }
      result.value.then((data) => {
        step(() => it.next(data))
      }, (err) => {
        step(() => it.throw(err)) // 这里为了让抛错直接在 generator 消化,所以 step 内改传函数
      })
    }
    step(() => it.next())
  })
}

栈中平衡括号

文章有描述错误的地方欢迎指正交流。
"(()(" 返回 true

自定义事件写的有点问题

BasicSkill/基础篇/askself/answer.md
自定义事件,事件绑定addEventListener多了个s
然后事件派发应该是dispatchEvent

从 JavaScript 到 TypeScript

文中的案例代码已经上传到 TypeScript

TypeScript 并不是一个完全新的语言, 它是 JavaScript 的超集,为 JavaScript 的生态增加了类型机制,并最终将代码编译为纯粹的 JavaScript 代码。

TypeScript 简介

TypeScript 由 Microsoft(算上 Angular 2 的话加上 Google)开发和维护的一种开源编程语言。 它支持 JavaScript 的所有语法和语义,同时通过作为 ECMAScript 的超集来提供一些额外的功能,如类型检测和更丰富的语法。下图显示了 TypeScript 与 ES5,ES2015,ES2016 之间的关系。

使用 TypeScript 的原因

JavaScript 是一门弱类型语言,变量的数据类型具有动态性,只有执行时才能确定变量的类型,这种后知后觉的认错方法会让开发者成为调试大师,但无益于编程能力的提升,还会降低开发效率。TypeScript 的类型机制可以有效杜绝由变量类型引起的误用问题,而且开发者可以控制对类型的监控程度,是严格限制变量类型还是宽松限制变量类型,都取决于开发者的开发需求。添加类型机制之后,副作用主要有两个:增大了开发人员的学习曲线,增加了设定类型的开发时间。总体而言,这些付出相对于代码的健壮性和可维护性,都是值得的。

此外,类型注释是 TypeScript 的内置功能之一,允许文本编辑器和 IDE 可以对我们的代码执行更好的静态分析。这意味着我们可以通过自动编译工具的帮助,在编写代码时减少错误,从而提高我们的生产力。

对 TypeScript 的简介到此,接下来对其特有的知识点进行简单概括总结。

数据类型

函数

为函数定义类型

我们可以给每个参数添加类型之后再为函数本身添加返回值类型。TypeScript 能够根据返回语句自动推断出返回值类型,因此我们通常省略它。下面函数 add, add2, add3 的效果是一样的,其中是 add3 函数是函数完整类型。

function add(x: string, y: string): string{
  return "Hello TypeScript"
}

let add2 = function(x: string, y: string): string{
  return "Hello TypeScript"
}

let add3: (x: string, y: string) => string = function(x: string, y: string): string{
  return "Hello TypeScript"
}

可选参数和默认参数

JavaScript 里,每个参数都是可选的,可传可不传。没传参的时候,它的值就是 undefined。 在 TypeScript 里我们可以在参数名旁使用 ? 实现可选参数的功能。 比如,我们想让 lastname 是可选的:

function buildName(firstName: string, lastname?: string){
  console.log(lastname ? firstName + "" + lastname : firstName)
}

let res1 = buildName("鸣","人")        // 鸣人
let res2 = buildName("鸣")             // 鸣
let res3 = buildName("鸣", "人", "君") // Supplied parameters do not match any signature of call target.

如果带默认值的参数出现在必须参数前面,用户必须明确的传入 undefined 值来获得默认值。 例如,我们重写上例子,让 firstName 是带默认值的参数:

function buildName2(firstName = "鸣", lastName?: string){
  console.log(firstName + "" + lastName)
}

let res4 = buildName2("人")            // 人
let res5 = buildName2(undefined, "人") // 鸣人

传统的 JavaScript 程序使用函数和基于原型的继承来创建可重用的组件,但对于熟悉使用面向对象方式的程序员来讲就有些棘手,因为他们用的是基于类的继承并且对象是由类构建出来的。 从 ECMAScript 2015,也就是 ECMAScript 6 开始,JavaScript 程序员将能够使用基于类的面向对象的方式。 使用 TypeScript,我们允许开发者现在就使用这些特性,并且编译后的 JavaScript 可以在所有主流浏览器和平台上运行,而不需要等到下个 JavaScript 版本。

class Person{
  name:string // 这个是对后文this.name类型的定义
  age:number
  constructor(name:string,age:number){
    this.name = name
    this.age = age
  }
  print(){
    return this.name + this.age
  }
}

let person:Person = new Person('muyy',23)
console.log(person.print()) // muyy23

我们在引用任何一个类成员的时候都用了 this。 它表示我们访问的是类的成员。其实这本质上还是 ES6 的知识,只是在 ES6 的基础上多上了对 this 字段和引用参数的类型声明。

继承

class Person{
  public name:string  // public、private、static 是 typescript 中的类访问修饰符
  age:number
  constructor(name:string,age:number){
    this.name = name
    this.age = age
  }
  tell(){
    console.log(this.name + this.age)
  }
}

class Student extends Person{
  gender:string
  constructor(gender:string){
    super("muyy",23)
    this.gender = gender
  }
  tell(){
    console.log(this.name + this.age + this.gender)
  }
}

var student = new Student("male")
student.tell()  // muyy23male

这个例子展示了 TypeScript 中继承的一些特征,可以看到其实也是 ES6 的知识上加上类型声明。不过这里多了一个知识点 —— 公共,私有,以及受保护的修饰符。TypeScript 里,成员默认为 public ;当成员被标记成 private 时,它就不能在声明它的类的外部访问;protected 修饰符与 private 修饰符的行为很相似,但有一点不同,protected 成员在派生类中仍然可以访问。

存储器

TypeScript 支持通过 getters/setters 来截取对对象成员的访问。 它能帮助你有效的控制对对象成员的访问。

对于存取器有下面几点需要注意的:
首先,存取器要求你将编译器设置为输出 ECMAScript 5 或更高。 不支持降级到 ECMAScript 3。 其次,只带有 get 不带有 set 的存取器自动被推断为 readonly。 这在从代码生成 .d.ts 文件时是有帮助的,因为利用这个属性的用户会看到不允许够改变它的值。

class Hello{
  private _name: string
  private _age: number
  get name(): string {
    return this._name
  }
  set name(value: string) {
    this._name = value
  }
  get age(): number{
    return this._age
  }
  set age(age: number) {
    if(age>0 && age<100){
      console.log("年龄在0-100之间") // 年龄在0-100之间
      return
    }
    this._age = age
  }
}

let hello = new Hello()
hello.name = "muyy"
hello.age = 23
console.log(hello.name) // muyy

接口

接口

TypeScript 的核心原则之一是对值所具有的结构进行类型检查。在 TypeScript 里,接口的作用就是为这些类型命名和为你的代码或第三方代码定义契约。

interface LabelValue{
  label: string
}

function printLabel(labelObj: LabelValue){
  console.log(labelObj.label)
}

let myObj = {
  "label":"hello Interface"
}
printLabel(myObj)

LabelledValue 接口就好比一个名字,它代表了有一个 label 属性且类型为 string 的对象。只要传入的对象满足上述必要条件,那么它就是被允许的。

另外,类型检查器不会去检查属性的顺序,只要相应的属性存在并且类型也是对的就可以。

可选属性

带有可选属性的接口与普通的接口定义差不多,只是在可选属性名字定义的后面加一个 ? 符号。可选属性的好处之一是可以对可能存在的属性进行预定义,好处之二是可以捕获引用了不存在的属性时的错误。

interface Person{
  name?:string
  age?:number
}

function printInfo(info:Person){
  console.log(info)
}

let info = {
  "name":"muyy",
  "age":23
}

printInfo(info) // {"name": "muyy", "age": 23}

let info2 = {
  "name":"muyy"
}

printInfo(info2) // {"name": "muyy"}

函数类型

接口能够描述 JavaScript 中对象拥有的各种各样的外形。除了描述带有属性的普通对象外,接口也可以描述函数类型。定义的函数类型接口就像是一个只有参数列表和返回值类型的函数定义。参数列表里的每个参数都需要名字和类型。定义后完成后,我们可以像使用其它接口一样使用这个函数类型的接口。

interface SearchFunc{
  (source: string, subString: string): boolean
}

let mySearch: SearchFunc
mySearch = function(source: string,subString: string){
  return source.search(subString) !== -1
}

console.log(mySearch("鸣人","鸣")) // true
console.log(mySearch("鸣人","缨")) // false

可索引类型

与使用接口描述函数类型差不多,我们也可以描述那些能够“通过索引得到”的类型,比如 a[10]ageMap["daniel"]。 可索引类型具有一个索引签名,它描述了对象索引的类型,还有相应的索引返回值类型。 让我们看如下例子:

interface StringArray{
  [index: number]: string
}

let MyArray: StringArray
MyArray = ["是","云","随","风"]
console.log(MyArray[2]) // 随

类类型

与 C# 或 Java 里接口的基本作用一样,TypeScript 也能够用它来明确的强制一个类去符合某种契约。

我们可以在接口中描述一个方法,在类里实现它,如同下面的 setTime 方法一样:

interface ClockInterface{
  currentTime: Date
  setTime(d: Date)
}

class Clock implements ClockInterface{
  currentTime: Date
  setTime(d: Date){
    this.currentTime = d
  }
  constructor(h: number, m: number) {}
}

继承接口

和类一样,接口也可以相互继承。 这让我们能够从一个接口里复制成员到另一个接口里,可以更灵活地将接口分割到可重用的模块里。

interface Shape{
  color: string
}

interface PenStroke{
  penWidth: number
}

interface Square extends Shape,PenStroke{
  sideLength: number
}

let s = <Square>{}
s.color = "blue"
s.penWidth = 100
s.sideLength = 10

模块

TypeScript 与 ECMAScript 2015 一样,任何包含顶级 import 或者 export 的文件都被当成一个模块。

export interface StringValidator{
  isAcceptable(s:string): boolean
}

var strReg = /^[A-Za-z]+$/
var numReg = /^[0-9]+$/

export class letterValidator implements StringValidator{
  isAcceptable(s:string): boolean{
    return strReg.test(s)
  }
}

export class zipCode implements StringValidator{
  isAcceptable(s: string): boolean{
    return s.length == 5 && numReg.test(s)
  }
}

泛型

软件工程中,我们不仅要创建一致的定义良好的 API,同时也要考虑可重用性。 组件不仅能够支持当前的数据类型,同时也能支持未来的数据类型,这在创建大型系统时为你提供了十分灵活的功能。
在像 C# 和 Java 这样的语言中,可以使用泛型来创建可重用的组件,一个组件可以支持多种类型的数据。 这样用户就可以以自己的数据类型来使用组件。

初探泛型

如下代码,我们给 Hello 函数添加了类型变量 T ,T 帮助我们捕获用户传入的类型(比如:string)。我们把这个版本的 Hello 函数叫做泛型,因为它可以适用于多个类型。 代码中 outputoutput2 是效果是相同的,第二种方法更加普遍,利用了类型推论 —— 即编译器会根据传入的参数自动地帮助我们确定 T 的类型:

function Hello<T>(arg:T):T{
  return arg;
}

let outPut = Hello<string>('Hello Generic');
let output2 = Hello('Hello Generic')

console.log(outPut);
console.log(outPut2);

参考资料

读书笔记-你不知道的 JavaScript (上)

《你不知道的JavaScript》系列丛书给出了很多颠覆以往对JavaScript认知的点, 读完上卷,受益匪浅,于是对其精华的知识点进行了梳理。

什么是作用域

作用域是一套规则,用于确定在何处以及如何查找变量。

编译原理

JavaScript是一门编译语言。在传统编译语言的流程中,程序中一段源代码在执行之前会经历三个步骤,统称为“编译”。

  • 分词/词法分析
    将字符串分解成有意义的代码块,代码块又称词法单元。比如程序var a = 2;会被分解为var、a、=、2、;
  • 解析/语法分析
    将词法单元流转换成一个由元素逐级嵌套所组成的代表了程序语法接口的书,又称“抽象语法树”。
  • 代码生成
    将抽象语法树转换为机器能够识别的指令。

理解作用域

作用域 分别与编译器、引擎进行配合完成代码的解析

  • 引擎执行时会与作用域进行交流,确定RHS与LHS查找具体变量,如果查找不到会抛出异常。
  • 编译器负责语法分析以及生成代码。
  • 作用域负责收集并维护所有变量组成的一系列查询,并确定当前执行的代码对这些变量的访问权限。

对于 var a = 2 这条语句,首先编译器会将其分为两部分,一部分是 var a,一部分是 a = 2。编译器会在编译期间执行 var a,然后到作用域中去查找 a 变量,如果 a 变量在作用域中还没有声明,那么就在作用域中声明 a 变量,如果 a 变量已经存在,那就忽略 var a 语句。然后编译器会为 a = 2 这条语句生成执行代码,以供引擎执行该赋值操作。所以我们平时所提到的变量提升,无非就是利用这个先声明后赋值的原理而已!

异常

对于 var a = 10 这条赋值语句,实际上是为了查找变量 a, 并且将 10 这个数值赋予它,这就是 LHS 查询。 对于 console.log(a) 这条语句,实际上是为了查找 a 的值并将其打印出来,这是 RHS 查询。

为什么区分 LHSRHS 是一件重要的事情?
在非严格模式下,LHS 调用查找不到变量时会创建一个全局变量,RHS 查找不到变量时会抛出 ReferenceError。 在严格模式下,LHS 和 RHS 查找不到变量时都会抛出 ReferenceError。

作用域的工作模式

作用域共有两种主要的工作模型。第一种是最为普遍的,被大多数编程语言所采用的词法作用域( JavaScript 中的作用域就是词法作用域)。另外一种是动态作用域,仍有一些编程语言在使用(比如Bash脚本、Perl中的一些模式等)。

词法作用域

词法作用域是一套关于引擎如何寻找变量以及会在何处找到变量的规则。词法作用域最重要的特征是它的定义过程发生在代码的书写阶段(假设没有使用 eval() 或 with )。来看示例代码:

function foo() {
  console.log(a);  // 2
}

function bar() {
  var a = 3;
  foo();
}

var a = 2;

bar()

词法作用域让foo()中的a通过RHS引用到了全局作用域中的a,因此会输出2。

动态作用域

而动态作用域只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套。因此,如果 JavaScript 具有动态作用域,理论上,下面代码中的 foo() 在执行时将会输出3。

function foo() {
  console.log(a);  // 3
}

function bar() {
  var a = 3;
  foo();
}

var a = 2;

bar()

函数作用域

匿名与具名

对于函数表达式一个最熟悉的场景可能就是回调函数了,比如

setTimeout( function() {
  console.log("I waited 1 second!")
}, 1000 )

这叫作匿名函数表达式。函数表达式可以匿名,而函数声明则不可以省略函数名。匿名函数表达式书写起来简单快捷,很多库和工具也倾向鼓励使用这种风格的代码。但它也有几个缺点需要考虑。

  • 匿名函数在栈追踪中不会显示出有意义的函数名,使得调试很困难。
  • 如果没有函数名,当函数需要引用自身时只能使用已经过期的 arguments.callee 引用,比如在递归中。另一个函数需要引用自身的例子,是在事件触发后事件监听器需要解绑自身。
  • 匿名函数省略了对于代码可读性 / 可理解性很重要的函数名。一个描述性的名称可以让代码不言自明。

始终给函数表达式命名是一个最佳实践:

setTimeout( function timeoutHandler() { // 我有名字了
  console.log("I waited 1 second!")
}, 1000 )

提升

先有声明还是先有赋值

考虑以下代码:

a = 2;

var a;

console.log(a); // 2

考虑另外一段代码

console.log(a); // undefined

var a = 2;

我们习惯将 var a = 2; 看作一个声明,而实际上 JavaScript 引擎并不这么认为。它将 var a 和 a = 2 当作两个单独的声明,第一个是编译阶段的任务,而第二个是执行阶段的任务。
这意味着无论作用域中的声明出现在什么地方,都将在代码本身被执行前首先进行处理。可以将这个过程形象地想象成所有的声明(变量和函数)都会被“移动”到各自作用域的最顶端,这个过程称为提升。

可以看出,先有声明后有赋值。

再来看以下代码:

foo();  // TypeError
bar();  // ReferenceError

var foo = function bar() {
  // ...
};

这个代码片段经过提升后,实际上会被理解为以下形式:

var foo;

foo();  // TypeError
bar();  // ReferenceError

foo = function() {
  var bar = ...self...
  // ...
};

这段程序中的变量标识符 foo() 被提升并分配给全局作用域,因此 foo() 不会导致 ReferenceError。但是 foo 此时并没有赋值(如果它是一个函数声明而不是函数表达式就会赋值)。foo()由于对 undefined 值进行函数调用而导致非法操作,因此抛出 TypeError 异常。另外即时是具名的函数表达式,名称标识符(这里是 bar )在赋值之前也无法在所在作用域中使用。

闭包

之前写过关于闭包的一篇文章深入浅出JavaScript之闭包(Closure)

循环和闭包

要说明闭包,for 循环是最常见的例子。

for (var i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i);
  }, i*1000 )
}

正常情况下,我们对这段代码行为的预期是分别输出数字 1~5,每秒一次,每次一个。但实际上,这段代码在运行时会以每秒一次的频率输出五次6。

它的缺陷在于:根据作用域的工作原理,尽管循环中的五个函数是在各个迭代中分别定义的,但是它们都被封闭在一个共享的全局作用域中,因此实际上只有一个i。因此我们需要更多的闭包作用域。我们知道IIFE会通过声明并立即执行一个函数来创建作用域,我们来进行改进:

for (var i = 1; i <= 5; i++) {
  (function() {
    var j = i;
    setTimeout( function timer() {
      console.log(j);
    }, j*1000 )
  })();
}

还可以对这段代码进行一些改进:

for (var i = 1; i <= 5; i++) {
  (function(j) {
    setTimeout( function timer() {
      console.log(j);
    }, j*1000 )
  })(i);
}

在迭代内使用 IIFE 会为每个迭代都生成一个新的作用域,使得延迟函数的回调可以将新的作用域封闭在每个迭代内部,每个迭代中都会含有一个具有正确值的变量供我们访问。

重返块作用域

我们使用 IIFE 在每次迭代时都创建一个新的作用域。换句话说,每次迭代我们都需要一个块作用域。我们知道 let 声明可以用来劫持块作用域,那我们可以进行这样改:

for (var i = 1; i <= 5; i++) {
  let j = i;
  setTimeout( function timer() {
    console.log(j);
  }, j*1000 )
}

本质上这是将一个块转换成一个可以被关闭的作用域。

此外,for循环头部的 let 声明还会有一个特殊行为。这个行为指出每个迭代都会使用上一个迭代结束时的值来初始化这个变量。

for (let i = 1; i <= 5; i++) {
  setTimeout( function timer() {
    console.log(i);
  }, i*1000 )
}

this全面解析

之前写过一篇深入浅出JavaScript之this。我们知道this是在运行时进行绑定的,并不是在编写时绑定,它的上下文取决于函数调用时的各种条件。this的绑定和函数声明的位置没有任何关系,只取决于函数的调用方式。

this词法

来看下面这段代码的问题:

var obj = {
  id: "awesome",
  cool: function coolFn() {
    console.log(this.id);
  }
};

var id = "not awesome";

obj.cool();  // awesome

setTimeout( obj.cool, 100); // not awesome

obj.cool() 与 setTimeout( obj.cool, 100 ) 输出结果不一样的原因在于 cool() 函数丢失了同 this 之间的绑定。解决方法最常用的是 var self = this;

var obj = {
  count: 0,
  cool: function coolFn() {
    var self = this;

    if (self.count < 1) {
      setTimeout( function timer(){
        self.count++;
        console.log("awesome?");
      }, 100)
    }
  }
}

obj.cool(); // awesome?

这里用到的知识点是我们非常熟悉的词法作用域。self 只是一个可以通过词法作用域和闭包进行引用的标识符,不关心 this 绑定的过程中发生了什么。

ES6 中的箭头函数引人了一个叫作 this 词法的行为:

var obj = {
  count: 0,
  cool: function coolFn() {
    if (this.count < 1) {
      setTimeout( () => {
        this.count++;
        console.log("awesome?");
      }, 100)
    }
  }
}

obj.cool(); // awesome?

箭头函数弃用了所有普通 this 绑定规则,取而代之的是用当前的词法作用域覆盖了 this 本来的值。因此,这个代码片段中的箭头函数只是"继承"了 cool() 函数的 this 绑定。

但是箭头函数的缺点就是因为其是匿名的,上文已介绍过具名函数比匿名函数更可取的原因。而且箭头函数将程序员们经常犯的一个错误给标准化了:混淆了 this 绑定规则和词法作用域规则。

箭头函数不仅仅意味着可以少写代码。本书的作者认为使用 bind() 是更靠得住的方式。

var obj = {
  count: 0,
  cool: function coolFn() {
    if (this.count < 1) {
      setTimeout( () => {
        this.count++;
        console.log("more awesome");
      }.bind( this ), 100)
    }
  }
}

obj.cool(); // more awesome

绑定规则

函数在执行的过程中,可以根据下面这4条绑定规则来判断 this 绑定到哪。

  • 默认绑定
    • 独立函数调用
  • 隐式绑定
    • 当函数引用有上下文对象时,隐式绑定规则会把函数调用中的 this 绑定到这个上下文对象
  • 显示绑定
    • call/apply
    • bind(本质是对call/apply函数的封装 fn.apply( obj, arguments )
    • 第三方库的许多函数都提供了一个可选的参数(上下文),其作用和 bind() 一样,确保回调函数使用指定的 this
  • new 绑定
    • JavaScript 中的 new 机制实际上和面向类的语言完全不同
    • 实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”

书中对4条绑定规则的优先级进行了验证,得出以下的顺序优先级:

  • 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
  • 函数是否通过 call、apply(显式绑定)或者硬绑定(bind)调用?如果是的话,this 绑定的是指定对象。
  • 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
  • 如果都不是的话,使用默认绑定。在严格模式下,绑定到 undefined,否则绑定到全局对象。

被忽略的 this

如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认规则。

什么时候会传入 null/undefined 呢?一种非常常见的做法是用 apply(..) 来“展开”一个数组,并当作参数传入一个函数。类似地,bind(..) 可以对参数进行柯里化(预先设置一些参数),如下代码:

function foo(a, b) {
  console.log( "a:" + a + ", b:" + b );
}

// 把数组"展开"成参数
foo.apply(null, [2, 3]); // a:2, b:3

// 使用 bind(..) 进行柯里化
var bar = foo.bind( null, 2);
bar(3); // a:2, b:3

其中 ES6 中,可以用 ... 操作符代替 apply(..) 来“展开”数组,但是 ES6 中没有柯里化的相关语法,因此还是需要使用 bind(..)。

使用 null 来忽略 this 绑定可能产生一些副作用。如果某个函数(比如第三库中的某个函数)确实使用了 this ,默认绑定规则会把 this 绑定到全局对象,这将导致不可预计的后果。更安全的做法是传入一个特殊的对象,一个 “DMZ” 对象,一个空的非委托对象,即 Object.create(null)。

function foo(a, b) {
  console.log( "a:" + a + ", b:" + b );
}

var ø = Object.create(null);

// 把数组"展开"成参数
foo.apply( ø, [2, 3]); // a:2, b:3

// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2);
bar(3); // a:2, b:3

对象

JavaScript中的对象有字面形式(比如var a = { .. })和构造形式(比如var a = new Array(..))。字面形式更常用,不过有时候构造形式可以提供更多选择。

作者认为“JavaScript中万物都是对象”的观点是不对的。因为对象只是 6 个基础类型( string、number、boolean、null、undefined、object )之一。对象有包括 function 在内的子对象,不同子类型具有不同的行为,比如内部标签 [object Array] 表示这是对象的子类型数组。

复制对象

首先看下这个对象:

let a = {
  name: 'XiaoMing',
  habits:  ['a', 'b']
}

从这个对象,先抛出下面几个概念:

  • 普通的 = 赋值:b = a,如果修改了 b.name,那么 a.name 也会改变
  • 浅复制:如果修改了 b.name, a.name 不会改变,但是修改 b.habits 数组中的值,a.habits 的值也会改变
  • 深复制:b 的值改变,不会对 a 产生任何影响

对于 JSON 安全的对象(就是能用 JSON.stringify 序列号的字符串)来说,有一种巧妙的深复制方法:

var newObj = JSON.parse( JSON.stringify(someObj) )

但是这个方法存在以下坑:

  1. 如果对象里面有循环引用,会抛错

  2. 不能复制对象里面的 Date、Function、RegExp

  3. 所有的构造函数会指向 Object

看下面这个对象:

function anotherFunction() { /*..*/ }

var anotherObject = {
  c: true
};

var anotherArray = [];

var myObject = {
  a: 2,
  b: anotherObject, // 引用,不是复本!
  c: anotherArray, // 另一个引用!
  d: anotherFunction
};

anotherArray.push( myObject )

如何准确地表示 myObject 的复制呢?

这个例子中除了复制 myObject 以外还会复制 anotherArray。这时问题就来了,anotherArray 引用了 myObject, 所以又需要复制 myObject,这样就会由于循环引用导致死循环。该如何解决呢?

可以查看在 diana 库中的实践

相比于深复制,浅复制非常易懂并且问题要少得多,ES6 定义了 Object.assign(..) 方法来实现浅复制。 Object.assign(..) 方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举的自由键并把它们复制到目标对象,最后返回目标对象,就像这样:

var newObj = Object.assign( {}, myObject );

newObj.a; // 2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true

JavaScript 有一些近似类的语法元素(比如 new 和 instanceof), 后来的 ES6 中新增了一些如 class 的关键字。但是 JavaScript 实际上并没有类。类是一种设计模式,JavaScript 的机制其实和类完全不同。

  • 类的继承(委托)其实就是复制,但和其他语言中类的表现不同(其他语言类表现出来的都是复制行为),JavaScript 中的多态(在继承链中不同层次名称相同,但是功能不同的函数)并不表示子类和父类有关联,子类得到的只是父类的一份复本。
  • JavaScript 通过显示混入和隐式混入 call() 来模拟其他语言类的表现。此外,显示混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也是对象)只能复制引用,无法复制被引用的对象或者函数本身。

检查“类”关系

思考下面的代码:

function Foo() {
  // ...
}

Foo.prototype.blah = ...;

var a = new Foo();

我们如何找出� a 的“祖先”(委托关系)呢?

  • 方法一:a instanceof Foo; // true (对象 instanceof 函数)
  • 方法二: Foo.prototype.isPrototypeOf(a); // true (对象 isPrototypeOf 对象)
  • 方法三: Object.getPrototypeOf(a) === Foo.prototype; // true (Object.getPrototypeOf() 可以获取一个对象的 [[Prototype]]) 链;
  • 方法四: a.__proto__ == Foo.prototype; // true

构造函数

  • 函数不是构造函数,而是当且仅当使用 new 时,函数调用会变成“构造函数调用”。
  • 使用 new 会在 prototype 生成一个 constructor 属性,指向构造调用的函数。
  • constructor 并不表示被构造,而且 constructor 属性并不是一个不可变属性,它是不可枚举的,但它是可以被修改的。

对象关联

来看下面的代码:

var foo = {
  something: function() {
    console.log("Tell me something good...");
  }
};

var bar = Object.create(foo);

bar.something(); // Tell me something good...

Object.create(..)会创建一个新对象 (bar) 并把它关联到我们指定的对象 (foo),这样我们就可以充分发挥 [[Prototype]] 机制的为例(委托)并且避免不必要的麻烦 (比如使用 new 的构造函数调用会生成 .prototype 和 .constructor 引用)。

Object.create(null) 会创建一个拥有空链接的对象,这个对象无法进行委托。由于这个对象没有原型链,所以 instanceof 操作符无法进行判断,因此总是会返回 false 。这些特殊的空对象通常被称作“字典”,它们完全不会受到原型链的干扰,因此非常适合用来存储数据。

我们并不需要类来创建两个对象之间的关系,只需要通过委托来关联对象就足够了。而Object.create(..)不包含任何“类的诡计”,所以它可以完美地创建我们想要的关联关系。

此书的第二章第6部分就把面对类和继承行为委托两种设计模式进行了对比,我们可以看到行为委托是一种更加简洁的设计模式,在这种设计模式中能感受到Object.create()的强大。

ES6中的Class

来看一段 ES6中Class 的例子

class Widget {
  constructor(width, height) {
    this.width = width || 50;
    this.height = height || 50;
    this.$elem = null;
  }
  render($where){
    if (this.$elem) {
      this.$elem.css({
        width: this.width + "px"
        height: this.height + "px"
      }).appendTo($where);
    }
  }
}

class Button extends Widget {
  constructor(width, height, label) {
    super(width, height);
    this.label = label || "Default";
    this.$elem = $("<button>").text(this.label)
  }
  render($where) {
    super($where);
    this.$elem.click(this.onClick.bind(this));
  }
  onClick(evt) {
    console.log("Button '" + this.label + "' clicked!")
  }
}

除了语法更好看之外,ES6还有以下优点

  • 基本上不再引用杂乱的 .prototype 了。
  • Button 声明时直接 “继承” 了 Widget。
  • 可以通过 super(..)来实现相对多态,这样任何方法都可以引用原型链上层的同名方法。
  • class 字面语法不能声明属性(只能声明方法)。这是一种限制,但是它会排除掉许多不好的情况。
  • 可以通过 extends 很自然地扩展对象(子)类型。

但是 class 就是完美的吗?在传统面向类的语言中,类定义之后就不会进行修改,所以类的设计模式就不支持修改。但JavaScript 最强大的特性之一就是它的动态性,在使用 class 的有些时候还是会用到 .prototype 以及碰到 super (期望动态绑定然而静态绑定) 的问题,class 基本上都没有提供解决方案。

这也是本书作者希望我们思考的问题。

探索从 MVC 到 MVVM + Flux 架构模式的转变

在业务中一般 MVVM 框架一般都会配合上数据状态库(redux, mobx 等)一起使用,本文会通过一个小 demo 来讲述为什么会引人数据状态库。

从 MVC 到 MVVM 模式说起

传统 MVC 架构(如 JSP)在当今移动端流量寸土寸金的年代一个比较头疼的问题就是会进行大量的全局重复渲染。但是 MVC 架构是好东西,其对数据、视图、逻辑有了清晰的分工,于是前端 MVC 框架(比如 backbone.js) 出来了,对于很多业务规模不大的场景,前端 MVC 框架已经够用了,它也能做到前后端分离开发单页面应用,那么它的缺陷在哪呢?

拿 backbone.js 说,它的 Model 对外暴露了 set 方法,也就是说可以在不止一个 View 里修改同个 Model 的数据,然后一个 Model 的数据同时对应多个 View 的呈现,如下图所示。当业务逻辑过多时,多个 Model 和多个 View 就会耦合到一块,可以想到排查 bug 的时候会比较痛苦。

针对传统 MVC 架构性能低(多次全局渲染)以及前端 MVC 框架耦合度高(Model 和 View) 的痛处,MVVM 框架完美地解决了以上两点。可以参阅之前写的 MVVM 框架解析之双向绑定

only MVVM

假设有这么一个场景,在输入框中查询条件,点击查询,然后在列表中返回相应内容。如下图所示:

假设用 react 实现,思路大体是先调用查询接口,调用成功后将获取到的数据通过 setState 存进 list 中,列表显示部分代码如下:

const Decorate = (ListComponent) => class extends Component {
  constructor() {
    super()
    this.state = { list: [] }
  }

  componentDidMount() {
    fetch('./list.json')
      .then((res) => res.json())
      .then(result => this.setState({ list: result.data }))
  }

  render() {
    return (
      <ListComponent data={this.state.list} />
    )
  }
}

接着往封装的 Decorate 组件里,传入无状态函数构建的 List 组件用来展示列表数据,代码如下:

function List(props) {
  return (
    <div>
      {props.data.map(r =>
        <p key={r.id}>{r.content}</p>
      )}
    </div>
  )
}

可以看到 List 组件相当于是 View 层,而封装的 Decorate 组件相当于是 Model 层。但是这么做还是把业务逻辑写进了组件当中。而我们期望的是能得到一个纯粹的 Model 层和 View 层。接着一起看看 Flux 架构模式是如何解决这个问题的。

引人 Flux 架构模式

Flux 架构模式的 4 个重要组成部分以及它们的关系如上图所示,下文会根据 dispatch,store, action, view 的顺序逐步揭开 Flux 架构模式的面纱。

Flux 的源码中可以看出 Dispacher.js 是其的核心文件,其核心是基于事件的发布/订阅模式完成的,核心源码如下:

class Dispatcher {
  ...
  // 注册回调函数,
  register(callback) {
    var id = _prefix + this._lastID++;
    this._callbacks[id] = callback;
  }

  // 当调用 dispatch 的时候会调用 register 中注册的回调函数
  dispatch(payload) {
    this._startDispatching(payload);
    for (var id in this._callbacks) {
      this._invokeCallback(id);
    }
  }
}

回顾下之前的目的:让 Store 层变得纯粹。于是定义了一个变量 comments 用来专门存放列表数据,在了解 Dispatcher 的核心原理之后,当调用 dispatch(obj) 方法时,就可以把参数传递到事先注册的 register 函数中,代码如下:

// commentStore.js
let comments = []
const CommentStore = {
  getComment() {
    return comments
  }
}

dispathcer.register((action) => { // 调用 Dispatcher 实例上的 register 函数
  switch (action.type) {
    case 'GET_LIST_SUCCESS': {
      comments = action.comment
    }
  }
})

以及 action 中的函数如下:

// commentAction.js
const commentAction = {
  getList() {
    fetch('./list.json')
      .then((res) => res.json())
      .then(result =>
        dispathcer.dispatch({ // 调用 Dispatcher 实例上的 dispatch 函数
          type: 'GET_LIST_SUCCESS',
          comment: result.data
        }))
  }
}

但是似乎少了点什么,当 GET_LIST_SUCCESS 成功后,发现还缺少通知到页面再次调用 CommentStore.getComment() 的能力,所以再次引用事件发布/订阅模式,这次使用了 Node.js 提供的 events 模块,对 commentStore.js 文件进行修改,修改后代码如下:

let comments = []
const CommentStore = Object.assign({}, EventEmitter.prototype, {
  getComment() {
    return comments
  },

  emitChange() {
    this.emit('change')
  },

  addListener(callback) { // 提供给页面组件使用
    this.on('change', callback)
  }
})

appDispathcer.register((action) => {
  switch (action.type) {
    case 'GET_LIST_SUCCESS': {
      comments = action.comment
      CommentStore.emitChange() // 有了这行代码,也就有了通知页面再次进行调用 CommentStore.getComment 的能力
    }
  }
})

剩下最后一步了,就是整合 store 和 action 进页面中,代码如下:

class ComponentList extends Component {
  constructor() {
    super()
    this.state = {
      comment: commentStore.getComment()
    }
  }

  componentDidMount() {
    commentStore.addListener(() => this.setState({ // 注册函数,上面已经提过,供 store 使用
      comment: commentStore.getComment()
    }))
  }

  render() {
    return (
      <div>
        {this.state.comment.map(r =>
          <p key={r.id}>{r.content}</p>
        )}
      </div>
    )
  }
}

小结

单纯以 mvvm 构建应用会发现业务逻辑以及数据都耦合在组件之中,引入了 Flux 架构模式后数据和业务逻辑得到较好的分离。但是使用 Flux 有什么缺点呢?在下篇 《聊聊 Redux 架构模式》中会进行分析,下回见。

本文实践案例已上传至 stateManage

系列博客,欢迎 Star

redux middleware 源码分析

middleware 的由来

在业务中需要打印每一个 action 信息来调试,又或者希望 dispatch 或 reducer 拥有异步请求的功能。面对这些场景时,一个个修改 dispatch 或 reducer 代码有些乏力,我们需要一个可组合的、自由增减的插件机制,Redux 借鉴了 Koa 中 middleware 的**,利用它我们可以在前端应用中便捷地实现如日志打印、异步请求等功能。

比如在项目中,进行了如下调用后,redux 就集成了 thunk 函数调用以及打印日志的功能。

import thunk from 'redux-thunk'
import logger from '../middleware/logger'
const enhancer = applyMiddleware(thunk, logger),  // 以 redux-thunk、logger 中间件为例介绍中间件的使用
const store = createStore(rootReducer, enhancer)

下面追本溯源,来分析下源码。

applyMiddleware 调用入口

export default function createStore(reducer, preloadedState, enhancer) {
  // 通过下面代码可以发现,如果 createStore 传入 2 个参数,第二个参数相当于就是 enhancer
  if (typeof preloadedState === 'function' && typeof enhancer === 'undefined') {
    enhancer = preloadedState
    preloadedState = undefined
  }
  if (typeof enhancer !== 'undefined') {
    return enhancer(createStore)(reducer, preloadedState)
  }
  ...
}

由上述 createStore 源码发现,applyMiddleware 会进行 applyMiddleware(thunk, logger)(createStore)(reducer, preloadedState) 的调用。

applyMiddleware 源码如下

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = store.dispatch
    let chain = []

    const middlewareAPI = {
      getState: store.getState,                // 调用 redux 原生方法,获取状态
      dispatch: (...args) => dispatch(...args) // 调用 redux 原生 dispatch 方法
    }
    // 串行 middleware
    chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch // 返回加工过的 dispatch
    }
  }
}

可以发现 applyMiddleware 的作用其实就是返回加工过的 dispatch,下面会着重分析 middlewares 是如何串行起来的以及 dispatch 是如何被加工的。

串行 middleware

const middlewareAPI = {
  getState: store.getState,
  dispatch: (...args) => dispatch(...args)
}
chain = middlewares.map(middleware => middleware(middlewareAPI))
dispatch = compose(...chain)(store.dispatch)

观察上述代码后发现每个 middleware 都会传入参数 middlewareAPI,来看下中间件 logger 的源码 以及 redux-thunk 的源码, 发现中间件接受的第一个参数正是 ({ dispatch, getState })

// logger 源码
export default ({ dispatch, getState }) => next => action => {
  console.log(action)
  return next(action) // 经 compose 源码分析,此处 next 为 Store.dispatch
}
// redux-thunk 源码
export default ({ dispatch, getState }) => next => action => {
  if (typeof action === 'function') {
    return action(dispatch)
  }
  return next(action) // 此处 next 为 logger 中间件返回的 (action) => {} 函数
}

dispatch 是如何被加工的

接着上个小节,在 dispatch = compose(...chain)(store.dispatch) 中发现了 compose 函数,来看下 compose 的源码

export default function compose(...funcs) {
  // ...
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

compose 源码中的 funcs.reduce((a, b) => (...args) => a(b(...args))) 算是比较重要的一句,它的作用是返回组合参数后的函数,比如 compose(f, g, h) 等价于 (...args) => f(g(h(...args))),效果图如下所示,调用 this.props.dispatch() 后,会调用相应的中间件,最终会调用 redux 原生的 store.dispatch(),并且可以看到中间件调用的形式类似数据结构中的栈(先进后出)。

拿上个小节提到的 logger、redux-thunk 中间件为例,其 middleware 的内部串行调用方式如下,从而完成了 dispatch 功能的增强(支持如 this.props.dispatch(func) 的调用以及日志功能)。具体可以看 项目中的运用

action => {
  if (typeof action === 'function') {
    return action(dispatch)
  }
  return (action => {
    console.log(action)
    return store.dispatch(action)
  })(action)
}

参考文献

深入React技术栈

用 Node.js 把玩一番 Alfred Workflow

插件地址(集成Github、掘金、知乎、淘宝等搜索)

作为 Mac 上常年位居神器榜第一位的软件来说,Alfred 给我们带来的便利是不言而喻的,其中 workflow(工作流) 功不可没,在它上面可以轻松地查找任何 api;可以快速在豆瓣上搜到自己喜欢的电影、图书、音乐;可以快速把图片上传到图床 等等。

一些安利

附上一张个人装着的插件的截图。Caffeinate 插件能在指定时间使电脑不黑屏;在 Dash 插件上能轻松查任何文档;Youdao Translate 插件比系统自带的翻译方便许多。插件也是因人而异,大家可以在 Workflow List 上逛逛,各取所需。

在用了别人的插件感觉高大上后,便萌发了也写一个插件的想法,计划把自己常逛的网站集合成一个插件,使用特定的缩略词便可快速进行搜索数据,又看了官方称可以使用 bash, zsh, PHP, Ruby, Python, Perl, Apple Script 开发 Alfred Workflow。于是我选择了 Node.js 作为开发语言,开发了一款 commonSearch, 开发完效果如下(集成了Github、掘金、知乎、淘宝等搜索)。

开发阶段

在开发前,得先对一些特定的操作步骤和知识点有一定的认知,这样开发时就基本上没有大碍了。

前置步骤

可以先参考 如何去写一个第三方的 workflow 的开始部分, 完成基本工作流的搭建,如下图是我搭建好的基本工作流连线。

在 Script 中,可以看到 /usr/local/bin/node common_search.js 相当于就是在调用该插件的时候起了一个 node 服务,后面的 1 是为了区分当前调用的是哪个搜索手动传入 common_search.js 的,{query} 则是用户查询的名称。

使用 Node.js 调用 JSON API

最初开发参考了 知乎搜索 这个项目,它是基于 cheerio 这个模块对请求到的网页数据进行分析爬取,但是引入了 cheerio 后,插件体积多了 2M 多,这对于一个插件来说太不友好了,所以这可能是 python 之类的语言更适合开发类似插件的原因吧(猜想:python 不需要引人第三方库就能进行爬虫),于是我开始选择提供 JSON API 的接口,比如找寻掘金返回数据的接口。首先打开 chrome 控制台,这可能对前端工程师比较熟悉了。

从而找到了掘金返回搜索数据的接口是 https://search-merger-ms.juejin.im/v1/search?query={query}&page=0&raw_result=false&src=web

接着愉快地使用 node 提供的 https 模块,这里有一个注意点,http.get() 回调中的 res 参数不是正文,而是 http.ClientResponse 对象,所以我们需要组装内容。

var options = {
    host: 'search-merger-ms.juejin.im',
    path: '/v1/search?query=' + encodeURI(keyword) + '&page=0&raw_result=false&src=web'
  }
  https.get(options, function (res) {
    res.on('data', (chunk) => {
      var content += chunk
    }).on('end', function () {
      var jsonContent = JSON.parse(content) && JSON.parse(content).d
      var result_array = []
      for (var i = 0; i < jsonContent.length; i++) {
        if (jsonContent[i].user.jobTitle === '') {
          result_array.push({
            title:
            subtitle:
            arg:
            icon: {
              path: join(__dirname, 'xx.png'),
            },
            mods: {
              cmd: {}
            }
          })
        }
      }
      content = ''
      console.log(JSON.stringify({
        items: result_array
      }))
    })
  })

这种方法应该是最直接的调用 JSON API 的方案了,当然也可以引人第三方模块 request 后解析 JSON,示例如下:

var request = require('request')

var url = 'search-merger-ms.juejin.im/v1/search?query=' + encodeURI(keyword) + '&page=0&raw_result=false&src=web'

request.get({
    url: url,
    json: true,
    headers: {'User-Agent': 'request'}
  }, (err, res, data) => {
    if (err) {
      console.log('Error:', err);
    } else if (res.statusCode !== 200) {
      console.log('Status:', res.statusCode);
    } else {
      // data is already parsed as JSON:
      console.log(data.html_url);
    }
});

还有一点要注意的是返回值的字段是固定的,具体可以参考它的官方解释,琢磨了好久才把 JS 中的 Icon 自定义的格式找出来。

title: 主标题
subtitle: 内容行
arg: 跳转链接
icons: 图标
mods:定制键盘按键的方法

对于 Github、掘金、知乎、淘宝的搜索都是基于以上思路进行开发的,就是对于具体返回的 JSON 数据进行了不同处理,虽然粗糙,但也算完成了第一个 Alfred Workflow 插件的开发。

尾声

本文的知识点写的不是特别丰满,一是就是对开发这个插件的小结,另外就是抛砖引玉了,能让更多的小伙伴了解开发一个插件并不是难事,同时让更多的朋友开发出更多有意义,有趣的 alfred-workflow 插件也算是本文分享的一个初衷了。

2018 年终总结 —— 缘见

度过在点我达两年欢快的时光,开启在拼多多的新的旅程。

life

  • 2 月: 在老家的方岩山上跨了年;
  • 3 月: 和 w 做了过山车;
  • 4 月:面基了 小小倩; 参加了杭州的草莓音乐节;
  • 5 月:参加了掘金在杭的线下交流会, 初识染陌阿相、静霞等掘金小伙伴,缘起;
  • 6 月:和小伙伴们游玩崇明岛,逛了魔都夜景;参加了 GraphQL 的专题技术分享, 和带我入门前端的 scott 老师握了手;
  • 9 月: 和小伙伴们在浙大玉泉校区参加 nodeparty;
  • 11 月:面基了刃捷兄,参加了 VueConf;加入前端九部;期间用脚本叫 r 起床并播报天气预报,脚本坚持了两周;
  • 12 月:去黄山山顶吃到了热腾腾的泡面; 在 nodeparty 上, 贺老对语言特性的深刻见解只有敬佩。
  • 1 月:接连两天的 SEE`、d2 大会除了干货满满,俨然成为了追星大会。见到了玉伯、张秋怡等大大的真容,和情封前辈进行了深度交流, 同时和九部的小伙伴也进行了线下聚餐。

Siren

Siren 是星巴克 Logo 上双尾女海妖的名字。本意是希望星巴克的咖啡就像 Siren 的歌声那样美妙, 除此之外它还有个引申意 —— 克制欲望。

18 年下半年的周末大多待在下城区金逸影城(也就是 18 年 VueConf 的举办地)的星巴克里看看书之类, 这家星巴克在喧嚣和安静之间保持了恰当好处, 坐在周围的可能是谈论艺术的大学教授,或者是带小朋友做作业的家长,又或者是看书的同学。自由、温馨,时间在这里可快可慢。

18 年 —— 见闻色

以下是对 18 年计划的 review

flag 完成情况
对知识点采取思维脑图的方式进行学习 创建了 blog 项目进行了知识点的整理输出
参与到一个千星 Star 的开源项目的改善,提高阅读源码的能力,阅读 6 本技术书籍,1 本非技术书籍 在开源项目的参与深度上有所欠缺, 书籍阅读指标基本达成
课外学习积累相关方面知识并尝试用到公司项目中 指标达成
避免讲话结巴,加强语言组织能力和逻辑能力,没想清楚问题之前不要轻易回答 沟通交流能力需持续加强
加强自控能力, 规定的时间做规定的事情,做到按时起睡,不晚于 8 点半起床 自控能力有所提高,按时起睡指标未达成

19 年 —— 武装色

  • 阅读 5 本技术书籍,1 本非技术书籍,学习函数式编程
  • 落地一个有价值的项目
  • 深入 React.js 社区,提一次 pr
  • 研读 node.js 文档
  • 提升沟通交流能力, 做一次 party 分享
  • 请挚友喝星巴克(保底 12 杯)

感谢

感恩亦师亦友的愚安 boss, 以及给我帮助的点我达小伙伴们; 感激对我给予肯定的刃捷、明江前辈; 感激与我聊到凌晨的兵长兄; 感谢给我提供前行指引的死月、芙兰姐; 感谢掘金社区让我结识了小小倩、染陌、相学长等好友。感谢所有内推、面试过我的前辈, 让我认识到自己的不足。最后感谢所有杭州的朋友们,期待下次更好的遇见!

Node.js 异步异闻录

提到 Node.js, 我们脑海就会浮现异步、非阻塞、单线程等关键词,进一步我们还会想到 buffer、模块机制、事件循环、进程、V8、libuv 等知识点。本文起初旨在理顺 Node.js 以上易混淆概念,然而一入异步深似海,本文尝试基于 Node.js 的异步展开讨论,其他的主题只能日后慢慢补上了。(附:亦可以把本文当作是朴灵老师所著的《深入浅出 Node.js》一书的小结)。

异步 I/O

Node.js 正是依靠构建了一套完善的高性能异步 I/O 框架,从而打破了 JavaScript 在服务器端止步不前的局面。

异步 I/O VS 非阻塞 I/O

听起来异步和非阻塞,同步和阻塞是相互对应的,从实际效果而言,异步和非阻塞都达到了我们并行 I/O 的目的,但是从计算机内核 I/O 而言,异步/同步和阻塞/非阻塞实际上是两回事。

注意,操作系统内核对于 I/O 只有两种方式:阻塞与非阻塞。

调用阻塞 I/O 的过程:

调用非阻塞 I/O 的过程:

在此先引人一个叫作轮询的技术。轮询不同于回调,举个生活例子,你有事去隔壁寝室找同学,发现人不在,你怎么办呢?方法1,每隔几分钟再去趟隔壁寝室,看人在不;方法2,拜托与他同寝室的人,看到他回来时叫一下你;那么前者是轮询,后者是回调。

再回到主题,阻塞 I/O 造成 CPU 等待浪费,非阻塞 I/O 带来的麻烦却是需要轮询去确认是否完全完成数据获取。从操作系统的这个层面上看,对于应用程序而言,不管是阻塞 I/O 亦或是 非阻塞 I/O,它们都只能是一种同步,因为尽管使用了轮询技术,应用程序仍然需要等待 I/O 完全返回。

Node 的异步 I/O

完成整个异步 I/O 环节的有事件循环、观察者、请求对象以及 I/O 线程池。

事件循环

在进程启动的时候,Node 会创建一个类似于 whlie(true) 的循环,每一次执行循环体的过程我们称为 Tick。

每个 Tick 的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在相关的回调函数,就执行他们。然后进入下一个循环,如果不再有事件处理,就退出进程。

伪代码如下:

while(ture) {
  const event = eventQueue.pop()
  if (event && event.handler) {
    event.handler.execute()  // execute the callback in Javascript thread
  } else {
    sleep() // sleep some time to release the CPU do other stuff
  }
}

观察者

每个 Tick 的过程中,如何判断是否有事件需要处理,这里就需要引入观察者这个概念。

每个事件循环中有一个或多个观察者,而判断是否有事件需要处理的过程就是向这些观察者询问是否有要处理的事件。

在 Node 中,事件主要来源于网络请求、文件 I/O 等,这些事件都有对应的观察者。

请求对象

对于 Node 中的异步 I/O 而言,回调函数不由开发者来调用,在 JavaScript 发起调用到内核执行完 id 操作的过渡过程中,存在一种中间产物,它叫作请求对象。

请求对象是异步 I/O 过程中的重要中间产物,所有状态都保存在这个对象中,包括送入线程池等待执行以及 I/O 操作完后的回调处理

fs.open() 为例:

fs.open = function(path, flags, mode, callback) {
  bingding.open(
    pathModule._makeLong(path),
    stringToFlags(flags),
    mode,
    callback
  )
}

fs.open 的作用就是根据指定路径和参数去打开一个文件,从而得到一个文件描述符。

从前面的代码中可以看到,JavaScript 层面的代码通过调用 C++ 核心模块进行下层的操作。

从 JavaScript 调用 Node 的核心模块,核心模块调用 C++ 内建模块,内建模块通过 libuv 进行系统调用,这是 Node 里经典的调用方式。

libuv 作为封装层,有两个平台的实现,实质上是调用了 uv_fs_open 方法,在 uv_fs_open 的调用过程中,会创建一个 FSReqWrap 请求对象,从 JavaScript 层传入的参数和当前方法都被封装在这个请求对象中。回调函数则被设置在这个对象的 oncomplete_sym 属性上。

req_wrap -> object_ -> Set(oncomplete_sym, callback)

对象包装完毕后,在 Windows 下,则调用 QueueUserWorkItem() 方法将这个 FSReqWrap 对象推人线程池中等待执行。

至此,JavaScript 调用立即返回,由 JavaScript 层面发起的异步调用的第一阶段就此结束(即上图所注释的异步 I/O 第一部分)。JavaScript 线程可以继续执行当前任务的后续操作,当前的 I/O 操作在线程池中等待执行,不管它是否阻塞 I/O,都不会影响到 JavaScript 线程的后续操作,如此达到了异步的目的。

执行回调

组装好请求对象、送入 I/O 线程池等待执行,实际上是完成了异步 I/O 的第一部分,回调通知是第二部分。

线程池中的 I/O 操作调用完毕之后,会将获取的结果储存在 req -> result 属性上,然后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操作已经完成,并将线程归还线程池。

在这个过程中,我们动用了事件循环的 I/O 观察者,在每次 Tick 的执行过程中,它会调用 IOCP 相关的 GetQueuedCompletionStatus 方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到 I/O 观察者的队列中,然后将其当做事件处理。

I/O 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,取出 oncomplete_sym 属性作为方法,然后调用执行,以此达到调用 JavaScript 中传入的回调函数的目的。

小结

通过介绍完整个异步 I/O 后,有个需要重视的观点是 JavaScript 是单线程的,Node 本身其实是多线程的,只是 I/O 线程使用的 CPU 比较少;还有个重要的观点是,除了用户的代码无法并行执行外,所有的 I/O (磁盘 I/O 和网络 I/O) 则是可以并行起来的。

异步编程

Node 是首个将异步大规模带到应用层面的平台。通过上文所述我们了解了 Node 如何通过事件循环实现异步 I/O,有异步 I/O 必然存在异步编程。异步编程的路经历了太多坎坷,从回调函数、发布订阅模式、Promise 对象,到 generator、asycn/await。趁着异步编程这个主题刚好把它们串起来理理。

异步 VS 回调

对于刚接触异步的新人,很大几率会混淆回调 (callback) 和异步 (asynchronous) 的概念。先来看看维基的 Callback 条目:

In computer programming, a callback is any executable code that is passed as an argument to other code

因此,回调本质上是一种设计模式,并且 jQuery (包括其他框架)的设计原则遵循了这个模式。

在 JavaScript 中,回调函数具体的定义为:函数 A 作为参数(函数引用)传递到另一个函数 B 中,并且这个函数 B 执行函数 A。我们就说函数 A 叫做回调函数。如果没有名称(函数表达式),就叫做匿名回调函数。

因此 callback 不一定用于异步,一般同步(阻塞)的场景下也经常用到回调,比如要求执行某些操作后执行回调函数。讲了这么多让我们来看下同步回调和异步回调的例子:

同步回调:

function f2() {
  console.log('f2 finished')
}

function f1(cb) {
  cb()
  console.log('f1 finished')
}

f1(f2)  // 得到的结果是 f2 finished, f1 finished

异步回调:

function f2() {
  console.log('f2 finished')
}

function f1(cb) {
  setTimeout(cb, 1000) // 通过 setTimeout() 来模拟耗时操作
  console.log('f1 finished')
}

f1(f2)  // 得到的结果是 f1 finished, f2 finished

小结:回调可以进行同步也可以异步调用,但是 Node.js 提供的 API 大多都是异步回调的,比如 buffer、http、cluster 等模块。

发布/订阅模式

事件发布/订阅模式 (PubSub) 自身并无同步和异步调用的问题,但在 Node 的 events 模块的调用中多半伴随事件循环而异步触发的,所以我们说事件发布/订阅广泛应用于异步编程。它的应用非常广泛,可以在异步编程中帮助我们完成更松的解耦,甚至在 MVC、MVVC 的架构中以及设计模式中也少不了发布-订阅模式的参与。

以 jQuery 事件监听为例

$('#btn').on('myEvent', function(e) {  // 触发事件
  console.log('I am an Event')
})

$('#btn').trigger('myEvent')           // 订阅事件

可以看到,订阅事件就是一个高阶函数的应用。事件发布/订阅模式可以实现一个事件与多个回调函数的关联,这些回调函数又称为事件侦听器。下面我们来看看发布/订阅模式的简易实现。

var PubSub = function() {
  this.handlers = {}
}

PubSub.prototype.subscribe = function(eventType, handler) { // 注册函数逻辑
  if (!(eventType in this.handlers)) {
    this.handlers[eventType] = []
  }
  this.handlers[eventType].push(handler)  // 添加事件监听器
  return this // 返回上下文环境以实现链式调用
}

PubSub.prototype.publish = function(eventType) {  // 发布函数逻辑
  var _args = Array.prototype.slice.call(arguments, 1)
  for (var i = 0, _handlers = this.handlers[eventType]; i < _handlers.length; i++) {  // 遍历事件监听器
    _handlers[i].apply(this, _args) // 调用事件监听器
  }
}

var event = new PubSub // 构造 PubSub 实例

event.subscribe('name', function(msg) {
  console.log('my name is ' + msg)  // my name is muyy
})

event.publish('name', 'muyy')

至此,一个简易的订阅发布模式就实现了。然而发布/订阅模式也存在一些缺点,创建订阅本身会消耗一定的时间与内存,也许当你订阅一个消息之后,之后可能就不会发生。发布-订阅模式虽然它弱化了对象与对象之间的关系,但是如果过度使用,对象与对象的必要联系就会被深埋,会导致程序难以跟踪与维护。

Promise/Deferred 模式

想象一下,如果某个操作需要经过多个非阻塞的 IO 操作,每一个结果都是通过回调,程序有可能会看上去像这个样子。这样的代码很难维护。这样的情况更多的会发生在 server side 的情况下。代码片段如下:

operation1(function(err, result1) {
  operation2(result1, function(err, result2) {
    operation3(result2, function(err, result3) {
      operation4(result3, function(err, result4) {
        callback(result4) // do something useful
      })
    })
  })
})

这时候,Promise 出现了,其出现的目的就是为了解决所谓的回调地狱的问题。让我们看下使用 Promise 后的代码片段:

promise()
  .then(operation1)
  .then(operation2)
  .then(operation3)
  .then(operation4)
  .then(function(value4) {
    // Do something with value4
  }, function (error) {
    // Handle any error from step1 through step4
  })
  .done()

可以看到,使用了第二种编程模式后能极大地提高我们的编程体验,接着就让我们自己动手实现一个支持序列执行的 Promise。(附:为了直观的在浏览器上也能感受到 Promise,为此也写了一段浏览器上的 Promise 用法示例)

在此之前,我们先要了解 Promise/A 提议中对单个异步操作所作的抽象定义,定义具体如下所示:

  • Promise 操作只会处在 3 种状态的一种:未完成态、完成态和失败态。
  • Promise 的状态只会出现从未完成态向完成态或失败态转化,不能逆反。完成态和失败态不能相互转化。
  • Promise 的状态一旦转化,将不能被更改。

Promise 的状态转化示意图如下:

除此之外,Promise 对象的另一个关键就是需要具备 then() 方法,对于 then() 方法,有以下简单的要求:

  • 接受完成态、错误态的回调方法。在操作完成或出现错误时,将会调用对应方法。
  • 可选地支持 progress 事件回调作为第三个方法。
  • then() 方法只接受 function 对象,其余对象将被忽略。
  • then() 方法继续返回 Promise 对象,已实现链式调用。

then() 方法的定义如下:

then(fulfilledHandler, errorHandler, progressHandler)

有了这些核心知识,接着进入 Promise/Deferred 核心代码环节:

var Promise = function() { // 构建 Promise 对象
  // 队列用于存储执行的回调函数
  this.queue = []
  this.isPromise = true
}

Promise.prototype.then = function (fulfilledHandler, errorHandler, progressHandler) { // 构建 Progress 的 then 方法
  var handler = {}
  if (typeof fulfilledHandler === 'function') {
    handler.fulfilled = fulfilledHandler
  }
  if (typeof errorHandler === 'function') {
    handler.error = errorHandler
  }
  this.queue.push(handler)
  return this
}

如上 Promise 的代码就完成了,但是别忘了 Promise/Deferred 中的后者 Deferred,为了完成 Promise 的整个流程,我们还需要触发执行上述回调函数的地方,实现这些功能的对象就叫作 Deferred,即延迟对象。

Promise 和 Deferred 的整体关系如下图所示,从中可知,Deferred 主要用于内部来维护异步模型的状态;而 Promise 则作用于外部,通过 then() 方法暴露给外部以添加自定义逻辑。

接着来看 Deferred 代码部分的实现:

var Deferred = function() {
  this.promise = new Promise()
}

// 完成态
Deferred.prototype.resolve = function(obj) {
  var promise = this.promise
  var handler
  while(handler = promise.queue.shift()) {
    if (handler && handler.fulfilled) {
      var ret = handler.fulfilled(obj)
      if (ret && ret.isPromise) { // 这一行以及后面3行的意思是:一旦检测到返回了新的 Promise 对象,停止执行,然后将当前 Deferred 对象的 promise 引用改变为新的 Promise 对象,并将队列中余下的回调转交给它
        ret.queue = promise.queue
        this.promise = ret
        return
      }
    }
  }
}

// 失败态
Deferred.prototype.reject = function(err) {
  var promise = this.promise
  var handler
  while (handler = promise.queue.shift()) {
    if (handler && handler.error) {
      var ret = handler.error(err)
      if (ret && ret.isPromise) {
        ret.queue = promise.queue
        this.promise = ret
        return
      }
    }
  }
}

// 生成回调函数
Deferred.prototype.callback = function() {
  var that = this
  return function(err, file) {
    if(err) {
      return that.reject(err)
    }
    that.resolve(file)
  }
}

接着我们以两次文件读取作为例子,来验证该设计的可行性。这里假设第二个文件读取依赖于第一个文件中的内容,相关代码如下:

var readFile1 = function(file, encoding) {
  var deferred = new Deferred()
  fs.readFile(file, encoding, deferred.callback())
  return deferred.promise
}

var readFile2 = function(file, encoding) {
  var deferred = new Deferred()
  fs.readFile(file, encoding, deferred.callback())
  return deferred.promise
}

readFile1('./file1.txt', 'utf8').then(function(file1) { // 这里通过 then 把两个回调存进队列中
  return readFile2(file1, 'utf8')
}).then(function(file2) {
  console.log(file2)   // I am file2.
})

最后可以看到控制台输出 I am file2,验证成功~,这个案例的完整代码可以点这里查看,并建议使用 node-inspector 进行断点观察,(这段代码里面有些逻辑确实很绕,通过断点调试就能较容易理解了)。

从 Promise 链式调用可以清晰地看到队列(先进先出)的知识,其有如下两个核心步骤:

  • 将所有的回调都存到队列中;
  • Promise 完成时,逐个执行回调,一旦检测到返回了新的 Promise 对象,停止执行,然后将当前 Deferred 对象的 promise 引用改变为新的 Promise 对象,并将队列中余下的回调转交给它;

至此,实现了 Promise/Deferred 的完整逻辑,Promise 的其他知识未来也会继续探究。

Generator

尽管 Promise 一定程度解决了回调地狱的问题,但是对于喜欢简洁的程序员来说,一大堆的模板代码 .then(data => {...}) 显得不是很友好。所以爱折腾的开发者们在 ES6 中引人了 Generator 这种数据类型。仍然以读取文件为例,先上一段非常简洁的 Generator + co 的代码:

co(function* () {
  const file1 = yield readFile('./file1.txt')
  const file2 = yield readFile('./file2.txt')
  console.log(file1)
  console.log(file2)
})

可以看到比 Promise 的写法简洁了许多。后文会给出 co 库的实现原理。在此之前,先归纳下什么是 Generator。可以把 Generator 理解为一个可以遍历的状态机,调用 next 就可以切换到下一个状态,其最大特点就是可以交出函数的执行权(即暂停执行),让我们看如下代码:

function* gen(x) {
  yield (function() {return 1})()
  var y = yield x + 2
  return y
}

// 调用方式一
var g = gen(1)
g.next()  // { value: 1, done: false }
g.next()  // { value: 3, done: false }
g.next()  // { value: undefined, done: true }

// 调用方式二
var g = gen(1)
g.next()  // { value: 1, done: false }
g.next()  // { value: 3, done: false }
g.next(10)  // { value: 10, done: true }

由此我们归纳下 Generator 的基础知识:

  1. Generator 生成迭代器后,等待迭代器的 next() 指令启动。
  2. 启动迭代器后,代码会运行到 yield 处停止。并返回一个 {value: AnyType, done: Boolean} 对象,value 是这次执行的结果,done 是迭代是否结束。并等待下一次的 next() 指令。
  3. next() 再次启动,若 done 的属性不为 true,则可以继续从上一次停止的地方继续迭代。
  4. 一直重复 2,3 步骤,直到 done 为 true。
  5. 通过调用方式二,我们可看到 next 方法可以带一个参数,该参数就会被当作上一个 yield 语句的返回值

另外我们注意到,上述代码中的第一种调用方式中的 y 值是 undefined,如果我们真想拿到 y 值,就需要通过 g.next(); g.next().value 这种方式取出。可以看出,Generator 函数将异步操作表示得很简洁,但是流程管理却不方便。这时候用于 Generator 函数的自动执行的 co 函数库 登场了。为什么 co 可以自动执行 Generator 函数呢?我们知道,Generator 函数就是一个异步操作的容器。它的自动执行需要一种机制,当异步操作有了结果,能够自动交回执行权。

两种方法可以做到这一点:

  • Thunk 函数。将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
  • Promise 对象。将异步操作包装成 Promise 对象,用 then 方法交回执行权。

co 函数库其实就是将两种自动自动执行器(Thunk 函数和 Promise 对象),包装成一个库。使用 co 的前提条件是,Generator 函数的 yield 命令后面,只能是 Thunk 函数或者是 Promise 对象。下面分别用以上两种方法对 co 进行一个简单的实现。

基于 Thunk 函数的自动执行

在 JavaScript 中,Thunk 函数就是指将多参数函数替换成单参数的形式,并且其只接受回调函数作为参数的函数。Thunk 函数的例子如下:

// 正常版本的 readFile(多参数)
fs.readFile(filename, 'utf8', callback)

// Thunk 版本的 readFile(单参数)
function readFile(filename) {
    return function(callback) {
        fs.readFile(filename, 'utf8', callback);
    };
}

在基于 Thunk 函数和 Generator 的知识上,接着我们来看看 co 基于 Thunk 函数的实现。(附:代码参考自co最简版实现)

function co(generator) {
  return function(fn) {
    var gen = generator()
    function next(err, result) {
      if(err) {
        return fn(err)
      }
      var step = gen.next(result)
      if (!step.done) {
        step.value(next) // 这里可以把它联想成递归;将异步操作包装成 Thunk 函数,在回调函数里面交回执行权。
      } else {
        fn(null, step.value)
      }
    }
    next()
  }
}

用法如下:

co(function* () {  // 把 function*() 作为参数 generator 传入 co 函数
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')

  console.log(file1) // I'm file1
  console.log(file2) // I'm file2
  return 'done'
})(function(err, result) {  // 这部分的 function 作为 co 函数内的 fn 的实参传入
  console.log(result) // done
})

上述部分关键代码已进行注释,下面对 co 函数里的几个难点进行说明:

  • var step = gen.next(result), 前文提到的一句话在这里就很有用处了:next方法可以带一个参数,该参数就会被当作上一个yield语句的返回值;在上述代码的运行中一共会经过这个地方 3 次,result 的值第一次是空值,第二次是 file1.txt 的内容 I'm file1,第三次是 file2.txt 的内容 I'm file2。根据上述关键语句的提醒,所以第二次的内容会作为 file1 的值(当作上一个yield语句的返回值),同理第三次的内容会作为 file2 的值。
  • 另一处是 step.value(next), step.value 就是前面提到的 thunk 函数返回的 function(callback) {}, next 就是传入 thunk 函数的 callback。这句代码是条递归语句,是这个简易版 co 函数能自动调用 Generator 的关键语句。

建议亲自跑一遍代码,多打断点,从而更好地理解,代码已上传github

基于 Promise 对象的自动执行

基于 Thunk 函数的自动执行中,yield 后面需跟上 Thunk 函数,在基于 Promise 对象的自动执行中,yield 后面自然要跟 Promise 对象了,让我们先构建一个 readFile 的
Promise 对象:

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

在基于前文 Promise 对象和 Generator 的知识上,接着我们来看看 co 基于 Promise 函数的实现:

function co(generator) {
  var gen = generator()

  function next(data) {
    var result = gen.next(data) // 同上,经历了 3 次,第一次是 undefined,第二次是 I'm file1,第三次是 I'm file2
    if (result.done) return result.value
    result.value.then(function(data) { // 将异步操作包装成 Promise 对象,用 then 方法交回执行权
      next(data)
    })
  }
  next()
}

用法如下:

co(function* generator() {
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')

  console.log(file1.toString())  // I'm file1
  console.log(file2.toString())  // I'm file2
})

这一部分的代码上传在这里,通过观察可以发现基于 Thunk 函数和基于 Promise 对象的自动执行方案的 co 函数设计思路几乎一致,也因此呼应了它们共同的本质 —— 当异步操作有了结果,自动交回执行权。

async

看上去 Generator 已经足够好用了,但是使用 Generator 处理异步必须得依赖 tj/co,于是 asycn 出来了。本质上 async 函数就是 Generator 函数的语法糖,这样说是因为 async 函数的实现,就是将 Generator 函数和自动执行器,包装进一个函数中。伪代码如下,(注:其中 automatic 的实现可以参考 async 函数的含义和用法中的实现)

async function fn(args){
  // ...
}

// 等同于

function fn(args) {
  return automatic(function*() {  // automatic 函数就是自动执行器,其的实现可以仿照 co 库自动运行方案来实现,这里就不展开了
    // ...
  })
}

接着仍然以上文的读取文件为例,来比较 Generator 和 async 函数的写法差异:

// Generator
var genReadFile = co(function*() {
  var file1 = yield readFile('./file1.txt')
  var file2 = yield readFile('./file2.txt')
})

// 改用 async 函数
var asyncReadFile = async function() {
  var file1 = await readFile('./file1.txt')
  var file2 = await 1  // 等同于同步操作(如果跟上原始类型的值)
}

总体来说 async/await 看上去和使用 co 库后的 generator 看上去很相似,不过相较于 Generator,可以看到 Async 函数更优秀的几点:

  • 内置执行器。Generator 函数的执行必须依靠执行器,而 Aysnc 函数自带执行器,调用方式跟普通函数的调用一样;
  • 更好的语义。async 和 await 相较于 * 和 yield 更加语义化;
  • 更广的适用性。前文提到的 co 模块约定,yield 命令后面只能是 Thunk 函数或 Promise 对象,而 async 函数的 await 命令后面则可以是 Promise 或者原始类型的值;
  • 返回值是 Promise。async 函数返回值是 Promise 对象,比 Generator 函数返回的 Iterator 对象方便,因此可以直接使用 then() 方法进行调用;

参考资料

JavaScript 中常见设计模式整理

开发中,我们或多或少地接触了设计模式,但是很多时候不知道自己使用了哪种设计模式或者说该使用何种设计模式。本文意在梳理常见设计模式的特点,从而对它们有比较清晰的认知。

JavaScript 中常见设计模式

各设计模式关键词

看完了上述设计模式后,把它们的关键词特点罗列出来,以后提到某种设计模式,进而联想相应的关键词和例子,从而心中有数。

设计模式 特点 案例
单例模式 一个类只能构造出唯一实例 创建菜单对象
策略模式 根据不同参数可以命中不同的策略 动画库里的算法函数
代理模式 代理对象和本体对象具有一致的接口 图片预加载
迭代器模式 能获取聚合对象的顺序和元素 each([1, 2, 3], cb)
发布-订阅模式 PubSub 瀑布流库
命令模式 不同对象间约定好相应的接口 按钮和命令的分离
组合模式 组合模式在对象间形成一致对待的树形结构 扫描文件夹
模板方法模式 父类中定好执行顺序 咖啡和茶
享元模式 减少创建实例的个数 男女模具试装
职责链模式 通过请求第一个条件,会持续执行后续的条件,直到返回结果为止 if else 优化
中介者模式 对象和对象之间借助第三方中介者进行通信 测试结束告知结果
装饰者模式 动态地给函数赋能 天冷了穿衣服,热了脱衣服
状态模式 每个状态建立一个类,状态改变会产生不同行为 电灯换挡
适配者模式 一种数据结构改成另一种数据结构 枚举值接口变更
观察者模式 当观察对象发生变化时自动调用相关函数 vue 双向绑定

参考文献

*《JavaScript设计模式与开发实践》

SEO 在 SPA 站点中的实践

SEO 在 SPA 站点中的实践

背景

观察基于 create-react-doc 搭建的文档站点, 发现网页代码光秃秃的一片(见下图)。这显然是单页应用 (SPA) 站点的通病 —— 不利于文档被搜索引擎搜索 (SEO)。

难道 SPA 站点就无法进行 SEO 了么, 那么 Gatsbynuxt 等框架又为何能作为不少博主搭建博客的首选方案呢, 此类框架赋能 SEO 的技术原理是什么呢? 在好奇心的驱动下, 笔者尝试对 creat-react-doc 进行赋能 SEO 之旅。

搜索引擎优化

在实践之前, 先从理论上分析为何单页应用不能被搜索引擎搜索到。核心在于 爬虫蜘蛛在执行爬取的过程中, 不会去执行网页中的 JS 逻辑, 所以隐藏在 JS 中的跳转逻辑也不会被执行

查看当前 SPA 站点打包后的代码, 除了一个根目录 index.html 外, 其它都是注入的 JS 逻辑, 因此浏览器自然不会对其进行 SEO。

此外, 搜索引擎详优化是一门较复杂的学问。如果你对 SEO 优化比较陌生, 建议阅读搜索引擎优化 (SEO) 新手指南 一文, Google 搜索中心给出了全面的 17 个最佳做法, 以及 33 个应避免的做法, 这也是笔者近期在实践的部分。

SEO 在 SPA 站点中的实践案例

在轻文档站点的背景前提下, 我们暂不考虑 SSR 方案。

对市面上文档站点的 SEO 方案调研后, 笔者总结为如下四类:

  • 静态模板渲染方案
  • 404 重定向方案
  • SSG 方案
  • 预渲染方案

静态模板渲染方案

静态模板渲染方案以 hexo 最为典型, 此类框架需要指定特定的模板语言(比如 pug)来开发主题, 从而达到网页内容直出的目的。

404 重定向方案

404 重定向方案的原理主要是利用 GitHub Pages 的 404 机制进行重定向。比较典型的案例有 spa-github-pagessghpa

但是遗憾的是 2019 年 Google 调整了爬虫算法, 因此此类重定向方案在当下是无利于 SEO 的。spa-github-pages 作者也表示如果需要 SEO 的话, 使用 SSG 方案或者付费方案 Netlify

SSG 方案

SSG 方案全称为 static site generator, 中文可译为路由静态化方案。社区上 nuxtGatsby 等框架赋能 SEO 的技术无一例外可以归类此类 SSG 方案。

以 nuxt 框架为例, 在约定式路由的基础上, 其通过执行 nuxt generate 命令将 vue 文件转化为静态网页。

例子:

-| pages/
---| about.vue/
---| index.vue/

静态化后变成:

-| dist/
---| about/
-----| index.html
---| index.html

经过路由静态化后, 此时的文档目录结构可以托管于任何一个静态站点服务商。

预渲染方案

经过上文对 SSG 方案的分析, 此时 SPA 站点的优化关键已经跃然纸上 —— 静态化路由。相较于 nuxt、Gatsby 等框架存在约定式路由的限制, create-react-doc 在目录结构上的组织灵活自由。它的建站理念是文件即站点, 同时它对存量 markdown 文档的迁移也十分便捷。

blog 项目结构为例, 其文档结构如下:

-| BasicSkill/
---| basic/
-----| DOM.md
-----| HTML5.md

静态化后应该变成:

-| BasicSkill/
---| basic/
-----| DOM
-------| index.html
-----| HTML5
-------| index.html

经过调研, 该构思与 prerender-spa-plugin 预渲染方案一拍即合。预渲染方案的原理可以见如下图:

至此技术选型定下为使用预渲染方案实现 SSG。

预渲染方案实践

create-react-doc 在预渲染方案实践的步骤简单概况如下(完整改动可见 mr):

  • 改造 hash 路由为 history 路由。因为 history 路由结构与文档静态化目录结构天然匹配。
export default function RouterRoot() {
  return (
-    <HashRouter>
+    <BrowserRouter>
      <RoutersContainer />
-    </HashRouter>
+    </BrowserRouter>
  )
}
  • 在开发环境、生成环境的基础上新增预渲染环境, 同时对路由进行环境匹配。其主要解决了资源文件主域名下的子路径的对应关系。过程比较曲折, 感兴趣的同学可以见 issue
const ifProd = env === 'prod'
+ const ifPrerender = window.__PRERENDER_INJECTED && window.__PRERENDER_INJECTED.prerender
+ const ifAddPrefix = ifProd && !ifPrerender

<Route
  key={item.path}
  exact
-  path={item.path}
+  path={ifAddPrefix ? `/${repo}${item.path}` : item.path}
  render={() => { ... }}
/>
  • 兼容 prerender-spa-plugin 在 webpack 5 的使用。

官方版本当前未支持 webpack 5, 详见 issue, 同时笔者存在对预渲染后执行回调的需求。因此当前 fork 了一份版本 出来, 解决了以上问题。

经过上述步骤的实践, 终于在 SPA 站点中实现了静态化路由

SEO 优化附加 buff, 站点秒开?

SEO 优化至此, 来看下站点优化前后 FP、FCP、LCP 等指标数据的变化。

blog 站点为例, 优化前后的指标数据如下(数据指标统计来自未使用梯子访问 gh-pages):

优化前: 接入预渲染方案前, 首次绘制(FP、FCP) 的时间节点在 8s 左右, LCP 在 17s 左右。

优化后: 接入预渲染方案后, 首次绘制时间节点在 1s 之内开始, LCP 在 1.5s 之内。

对比优化前后: 首屏绘制速度提升了 8 倍, 最大内容绘制速度提升 11 倍。本想优化 SEO, 结果站点性能优化的方式又 get 了一个。

生成站点地图 Sitemap

在完成预渲染实现站点路由静态化后, 距离 SEO 的目标又近了一步。暂且抛开 SEO 优化细节, 单刀直入 SEO 核心腹地 站点地图

站点地图 Sitemap 格式与各字段含义简单说明如下:

<?xml version="1.0" encoding="utf-8"?>
<urlset>
  <!-- 必填标签, 这是具体某一个链接的定义入口,每一条数据都要用 <url> 和 </url> 包含在里面, 这是必须的 -->
  <url>
    <!-- 必填, URL 链接地址,长度不得超过 256 字节 -->
    <loc>http://www.yoursite.com/yoursite.html</loc>
    <!-- 可以不提交该标签, 用来指定该链接的最后更新时间 -->
    <lastmod>2021-03-06</lastmod>
    <!-- 可以不提交该标签, 用这个标签告诉此链接可能会出现的更新频率 -->
    <changefreq>daily</changefreq>
    <!-- 可以不提交该标签, 用来指定此链接相对于其他链接的优先权比值,此值定于 0.0-1.0 之间 -->
    <priority>0.8</priority>
  </url>
</urlset>

上述 sitemap 中, lastmod、changefreq、priority 字段对 SEO 没那么重要, 可以见 how-to-create-a-sitemap

根据上述结构, 笔者开发了 create-react-doc 的站点地图生成包 crd-generator-sitemap, 其逻辑就是将预渲染的路由路径拼接成上述格式。

使用方只需在站点根目录的 config.yml 添加如下参数便可以在自动化发版过程中自动生成 sitemap

seo:
  google: true

将生成的站点地图往 Google Search Console 中提交试试吧,

最后验证下 Google 搜索站点优化前后效果。

优化前: 只搜索到一条数据。

优化后: 搜索到站点地图中声明的位置数据。

至此使用 SSG 优化 SPA 站点实现 SEO 的完整流程完整实现了一遍。后续便剩下参照 搜索引擎优化 (SEO) 新手指南 做一些 SEO 细节方面的优化以及支持更多搜索引擎了。

小结

本文从 SPA 站点实现 SEO 作为切入点, 先后介绍了 SEO 的基本原理, SEO 在 SPA 站点中的 4 种实践案例, 并结合 create-react-doc SPA 框架进行完整的 SEO 实践。

如果本文对您有所帮助, 欢迎 star反馈

相关链接

原生 JS 实现一个瀑布流插件

瀑布流布局中的图片有一个核心特点 —— 等宽不定等高,瀑布流布局在国内网网站都有一定规模的使用,比如pinterest花瓣网等等。那么接下来就基于这个特点开始瀑布流探索之旅。

基础功能实现

首先我们定义好一个有 20 张图片的容器,

<body>
  <style>
    #waterfall {
      position: relative;
    }
    .waterfall-box {
      float: left;
      width: 200px;
    }
  </style>
</body>
<div id="waterfall">
    <img src="images/1.png" class="waterfall-box">
    <img src="images/2.png" class="waterfall-box">
    <img src="images/3.png" class="waterfall-box">
    <img src="images/4.png" class="waterfall-box">
    <img src="images/5.png" class="waterfall-box">
    <img src="images/6.png" class="waterfall-box">
    ...
  </div>

由于未知的 css 知识点,丝袜最长的妹子把下面的空间都占用掉了。。。

接着正文,假如如上图,每排有 5 列,那第 6 张图片应该出现前 5 张图片哪张的下面呢?当然是绝对定位到前 5 张图片高度最小的图片下方。

那第 7 张图片呢?这时候把第 6 张图片和在它上面的图片当作是一个整体后,思路和上述是一致的。代码实现如下:

Waterfall.prototype.init = function () {
  ...
  const perNum = this.getPerNum() // 获取每排图片数
  const perList = []              // 存储第一列的各图片的高度
  for (let i = 0; i < perNum; i++) {
    perList.push(imgList[i].offsetHeight)
  }

  let pointer = this.getMinPointer(perList) // 求出当前最小高度的数组下标

  for (let i = perNum; i < imgList.length; i++) {
    imgList[i].style.position = 'absolute' // 核心语句
    imgList[i].style.left = `${imgList[pointer].offsetLeft}px`
    imgList[i].style.top = `${perList[pointer]}px`

    perList[pointer] = perList[pointer] + imgList[i].offsetHeight // 数组最小的值加上相应图片的高度
    pointer = this.getMinPointer(perList)
  }
}

细心的朋友也许发现了代码中获取图片的高度用到了 offsetHeight 这个属性,这个属性的高度之和等于图片高度 + 内边距 + 边框,正因为此,我们用了 padding 而不是 margin 来设置图片与图片之间的距离。此外除了offsetHeight 属性,此外还要理解 offsetHeightclientHeightoffsetTopscrollTop 等属性的区别,才能比较好的理解这个项目。css 代码简单如下:

.waterfall-box {
  float: left;
  width: 200px;
  padding-left: 10px;
  padding-bottom: 10px;
}

至此完成了瀑布流的基本布局,效果图如下:

scroll、resize 事件监听的实现

实现了初始化函数 init 以后,下一步就要实现对 scroll 滚动事件进行监听,从而实现当滚到父节点的底部有源源不断的图片被加载出来的效果。这时候要考虑一个点,是滚动到什么位置时触发加载函数呢?这个因人而异,我的做法是当满足 父容器高度 + 滚动距离 > 最后一张图片的 offsetTop 这个条件,即橙色线条 + 紫色线条 > 蓝色线条时触发加载函数,代码如下:

window.onscroll = function() {
  // ...
  if (scrollPX + bsHeight > imgList[imgList.length - 1].offsetTop) {// 浏览器高度 + 滚动距离 > 最后一张图片的 offsetTop
    const fragment = document.createDocumentFragment()
    for(let i = 0; i < 20; i++) {
      const img = document.createElement('img')
      img.setAttribute('src', `images/${i+1}.png`)
      img.setAttribute('class', 'waterfall-box')
      fragment.appendChild(img)
    }
    $waterfall.appendChild(fragment)
  }
}

因为父节点可能自定义节点,所以提供了对监听 scroll 函数的封装,代码如下:

  proto.bind = function () {
    const bindScrollElem = document.getElementById(this.opts.scrollElem)
    util.addEventListener(bindScrollElem || window, 'scroll', scroll.bind(this))
  }

  const util = {
    addEventListener: function (elem, evName, func) {
      elem.addEventListener(evName, func, false)
    },
  }

resize 事件的监听与 scroll 事件监听大同小异,当触发了 resize 函数,调用 init 函数进行重置就行。

使用发布-订阅模式和继承实现监听绑定

既然以开发插件为目标,不能仅仅满足于功能的实现,还要留出相应的操作空间给开发者自行处理。联想到业务场景中瀑布流中下拉加载的图片一般都来自 Ajax 异步获取,那么加载的数据必然不能写死在库里,期望能实现如下调用(此处借鉴了 waterfall 的使用方式),

const waterfall = new Waterfall({options})

waterfall.on("load", function () {
  // 此处进行 ajax 同步/异步添加图片
})

观察调用方式,不难联想到使用发布/订阅模式来实现它,关于发布/订阅模式,之前在 Node.js 异步异闻录 有介绍它。其核心**即通过订阅函数将函数添加到缓存中,然后通过发布函数实现异步调用,下面给出其代码实现:

function eventEmitter() {
  this.sub = {}
}

eventEmitter.prototype.on = function (eventName, func) { // 订阅函数
  if (!this.sub[eventName]) {
    this.sub[eventName] = []
  }
  this.sub[eventName].push(func) // 添加事件监听器
}

eventEmitter.prototype.emit = function (eventName) { // 发布函数
  const argsList = Array.prototype.slice.call(arguments, 1)
  for (let i = 0, length = this.sub[eventName].length; i < length; i++) {
    this.sub[eventName][i].apply(this, argsList) // 调用事件监听器
  }
}

接着,要让 Waterfall 能使用发布/订阅模式,只需让 Waterfall 继承 eventEmitter 函数,代码实现如下:

function Waterfall(options = {}) {
  eventEmitter.call(this)
  this.init(options) // 这个 this 是 new 的时候,绑上去的
}

Waterfall.prototype = Object.create(eventEmitter.prototype)
Waterfall.prototype.constructor = Waterfall

继承方式的写法吸收了基于构造函数继承和基于原型链继承两种写法的优点,以及使用 Object.create 隔离了子类和父类,关于继承更多方面的细节,可以另写一篇文章了,此处点到为止。

小优化

为了防止 scroll 事件触发多次加载图片,可以考虑用函数防抖与节流实现。在基于发布-订阅模式的基础上,定义了个 isLoading 参数表示是否在加载中,并根据其布尔值决定是否加载,代码如下:

let isLoading = false
const scroll = function () {
  if (isLoading) return false // 避免一次触发事件多次
  if (scrollPX + bsHeight > imgList[imgList.length - 1].offsetTop) { // 浏览器高度 + 滚动距离 > 最后一张图片的 offsetTop
    isLoading = true
    this.emit('load')
  }
}

proto.done = function () {
  this.on('done', function () {
    isLoading = false
    ...
  })
  this.emit('done')
}

这时候需要在调用的地方加上 waterfall.done, 从而告知当前图片已经加载完毕,代码如下:

const waterfall = new Waterfall({})
waterfall.on("load", function () {
  // 异步/同步加载图片
  waterfall.done()
})

项目地址

项目地址

此插件在 React 项目中的运用

项目简陋,不足之处在所难免,欢迎留下你们宝贵的意见。

hexo 摸爬滚打之进阶教程

写博客有三个层次,第一层次是借鉴居多的博文,第二层次是借鉴后经过消化后有一定量产出的博文,第三层次是原创好文居多的博文。在参考了大量前辈搭建hexo的心得后,此文尽量把一些别人未提到的点以及比较好用的点给提出来。所以你在参考本文的时候,应该已经过完了hexo。本文有以下内容:

  • 快速实现博客压缩
  • 文章链接唯一化
  • 添加酷炫的打赏二维码
  • 自定义JS和CSS
  • 添加酷炫的歌单模块以及播放器
  • github分支管理博客思路
  • 秒传图片到七牛云并展现在博客中
  • 将博客同时部署到github和coding

快速实现博客压缩

项目压缩也叫代码丑化, 分别对 html、css、js、images进行优化,即把重复的代码合并,把多余的空格去掉,用算法把 images 进行压缩。压缩后的博客,加载速度会有较大的提升,自然能留住更多游客。

蛮多朋友使用了gulp对博客进行压缩,这也是一个办法,但在社区逛了下,找到了一个比较好用的模块hexo-all-minifier,这个模块集成了对 html、css、js、image 的优化。安装上此模块后,只要在根目录下的_config.yml文件中加上如下字段就可对博客所有内容进行压缩。

html_minifier:
  enable: true
  ignore_error: false
  exclude:

css_minifier:
  enable: true
  exclude:
    - '*.min.css'

js_minifier:
  enable: true
  mangle: true
  output:
  compress:
  exclude:
    - '*.min.js'

image_minifier:
  enable: true
  interlaced: false
  multipass: false
  optimizationLevel: 2
  pngquant: false
  progressive: false

文章链接唯一化

也许你会数次更改文章题目或者变更文章发布时间,在默认设置下,文章链接都会改变,不利于搜索引擎收录,也不利于分享。唯一永久链接才是更好的选择。

安装

npm install hexo-abbrlink --save

站点配置文件中查找代码permalink,将其更改为:

permalink: posts/:abbrlink/  # “posts/ 可自行更换

这里有个知识点:

百度蜘蛛抓取网页的规则: 对于蜘蛛说网页权重越高、信用度越高抓取越频繁,例如网站的首页和内页。蜘蛛先抓取网站的首页,因为首页权重更高,并且大部分的链接都是指向首页。然后通过首页抓取网站的内页,并不是所有内页蜘蛛都会去抓取。

搜索引擎认为对于一般的中小型站点,3层足够承受所有的内容了,所以蜘蛛经常抓取的内容是前三层,而超过三层的内容蜘蛛认为那些内容并不重要,所以不经常爬取。出于这个原因所以permalink后面跟着的最好不要超过2个斜杠。

然后在站点配置文件中添加如下代码:

# abbrlink config
abbrlink:
  alg: crc32  # 算法:crc16(default) and crc32
  rep: hex    # 进制:dec(default) and hex

可选择模式:

  • crc16 & hex
  • crc16 & dec
  • crc32 & hex
  • crc32 & dec

添加酷炫的打赏二维码

看了好些博客,支付宝的收款码和微信的收款码都是分开的,且是没有美化过的二维码,让人打赏的欲望自然就下降了。来看一下我的赞赏二维码(支持微信和支付宝支付哟)

实现这个酷炫二维码的流程如下:

  • 首先,分别获得支付宝和微信的收款码
  • 接着到芝麻二维码里将两张二维码合并
  • 最后到第九工场生成自己喜欢的造型

讲生成的图片pay.png放到根目录的source文件中,并在主题配置文件中加上

alipay: /pay.png

打赏字体不闪动

修改文件next/source/css/_common/components/post/post-reward.styl,然后注释其中的函数wechat:hoveralipay:hover,如下:

/* 注释文字闪动函数
 #wechat:hover p{
    animation: roll 0.1s infinite linear;
    -webkit-animation: roll 0.1s infinite linear;
    -moz-animation: roll 0.1s infinite linear;
}
 #alipay:hover p{
   animation: roll 0.1s infinite linear;
    -webkit-animation: roll 0.1s infinite linear;
    -moz-animation: roll 0.1s infinite linear;
}
*/

自定义JS和CSS

博主用的是next主题,别的主题目录结构可能不太一样,但是整个框架是一样的,生成方式是一样的,所以引用方式也是相同的

添加自定义js样式

  • 首先把js文件放在\themes\next\source\js\src文件目录下
  • 然后找到\themes\next\layout目录下的布局文件_layout.swig
  • 把script引用代码加入到该文件中即可

<script type="text/javascript" src="/js/src/js文件名.js"></script>

添加自定义css样式

添加外部css样式和引用自定义js代码是一样的,在对应css文件夹内添加自定义外部css样式文件,然后在layout文件中添加引用即可。也可以在\themes\next\source\css\_custom\custom.styl文件中进行样式的添加。

添加酷炫的歌单模块以及播放器

这个模块借鉴了@小胡子哥。根据上面的自定义JS和CSS的知识点不难实现歌单模块以及播放器。效果如下图:

核心代码在\themes\next\source\js\src\music\nmlist中,点击看源码,其核心思路就是通过jsonp的方式对定义好的歌单进行调用。

在调试的过程中,发现了小胡子哥代码的一个bug:当点击一个专辑暂停后,再点击其他的专辑,这时候点击暂停、播放的控制逻辑有错误。经过排查在nmlist.js文件中的bind方法中加上了$("#nmPlayer").removeAttr("data-paused")解决了这个bug。

再接着玩的话,可以给播放器加上歌词的功能。这里有一篇相关文章, 有机会可以去把玩一番。

github分支管理博客思路

有一个问题,如果我电脑坏了怎么办,因为在github中的我们github.io项目是只有编译后的文件的,没有源文件的,也就是说,如果我们的电脑坏了,打不开了,我们的博客就不能进行更新了,所以我们要把我们的源文件也上传到github上。这个时候我可以选择新建一个仓库来存放源文件,也可以把源文件 push 到 user.github.io 的其他分支。我选择了后者。

创建muyy(任意)分支

创建两个分支:master 与 muyy,(这个muyy分支就是存放我们源文件的分支,我们只需要更新muyy分支上的内容据就好,master上的分支hexo编译的时候会更新的)

初始化仓库

然后我们再初始化仓库,重新对我们的代码进行版本控制

git init
git remote add origin <server>

<server>是指在线仓库的地址。origin是本地分支,remote add操作会将本地仓库映射到云端

将博客源文件上传至muyy分支

.gitignore文件作用是声明不被git记录的文件,blog根目录下的.gitignore是hexo初始化带来的,可以先删除或者直接编辑,对hexo不会有影响。建议.gitignore内添加以下内容:

/.deploy_git
/public
/_config.yml

.deploy_git是hexo默认的.git配置文件夹,不需要同步
public内文件是根据source文件夹内容自动生成,不需要备份,不然每次改动内容太多
即使是私有仓库,除去在线服务商员工可以看到的风险外,还有云服务商被攻击造成泄漏等可能,所以不建议将配置文件传上去

依次执行

git add .
git commit -m "..."
git push origin muyy

秒传图片到七牛云并展现在博客中

在markdown中写blog的朋友,想必这点是最烦恼的吧,一般来说都要手动上传图片到七牛云,再把链接写到markdown中。逛了逛社区,有人用phthon实现一个自动上传的脚本,但是我觉得还不是特别方便,这时在github上找到一个一键贴图工具qiniu-image-tool,它支持本地文件、截图、网络图片一键上传七牛云并返回图片引用。Mac 是基于 Alfred 的,其 windows 也有相应版本windows版本

按照其要求配置好以后,用截图软件截图后,或者本地图片后 copy,然后直接按设置好的 command+option+v,然后在图片成功上传到七牛云图床上,剪贴板上也有相应的连接。

将博客同时部署到 github 和 coding

通常我们把hexo托管在github,但是毕竟github是国外的,访问速度上还是有点慢,所以想也部署一套在国内的托管平台,目前gitcafe已经被coding收购了,所以就决定部署到coding。但是coding有个不好的地方就是访问自定义域名的站点时,不充值的话会有广告跳转页,所以我现在也是处于观望的态度,先把coding的环境代码也先布置好,等它哪一天广告跳转页没了,就把域名指过去。

coding 上创建一个新项目

这里只介绍 coding 上面如何创建项目,以及把本地 hexo 部署到 coding 上面

同步本地 hexo 到 coding 上

把获取到了ssh配置_config.yml文件中的deploy下,如果是第一次使用 coding 的话,需要设置SSH公钥,生成的方法可以参考coding帮助中心, 其实和 github 配置一模一样的。

本地打开 id_rsa.pub 文件,复制其中全部内容,填写到SSH_RSA公钥key下的一栏,公钥名称可以随意起名字。完成后点击“添加”,然后输入密码或动态码即可添加完成。

添加后,在git bash命令输入:

ssh -T git@git.coding.net

如果得到下面提示就表示公钥添加成功了:

Coding.net Tips : [Hello ! You've conected to Coding.net by SSH successfully! ]

想要同时部署到2个平台,就要修改博客根目录下面的_config.yml文件中的deploy如下
根据Hexo官方文档需要修改成下面的形式

deploy:
  type: git
  message: [message]
  repo:
    github: <repository url>,[branch]
    gitcafe: <repository url>,[branch]

所以我是这样的

deploy:
- type: git
  repo:
    github: https://github.com/MuYunyun/MuYunyun.github.io.git,master
    coding: git@git.coding.net:muyunyun/muyunyun.git,master

最后使用部署命令就能把博客同步到coding上面:

hexo deploy -g

pages服务方式部署

将代码上传至coding之后我们就要开启pages服务了,在pages页面我们只需要将部署来源选择为master分支,然后将自定义域名填写我们自己购买的域名就可以了

设置域名解析

现在要实现国内的走coding,海外的走github,只要配置2个CNAME就行。域名解析如下:

参考资料

2017 年终总结 —— 在路上

不久前年会主题征集活动中,我提交的主题是《在路上》,和骑手们一样,我们亦不是在路上呢?

恰逢踏入社会一年之际写下第一篇年终总结,百感交集,有初融入团队的喜悦、有独自完成需求的兴奋、有一次次团建的畅饮也有过为自己能力不足的懊恼以及与好友离别的感伤。

通过这篇总结,希望能记录些琐事,并给 2018 年的自己定下 promise。

启程

按捺不住走出校园的兴奋,走入我达新世界的大门。当时未进公司的时候,在朋友圈中看到的小伙伴们的照片,当时心中一阵惊叹,"这么帅!!要拖后腿了",不曾想到后来几个月和每个人发生的故事,已然成为生命中不可或缺的色彩了。时隔一年当再次回味这张照片时,似乎里面藏着初心。

春节结束后公司如期举行了年会,我在倒数第二个节目中独奏了二胡~,前端部门的年会中奖率也是全公司最高的,一度让其他部门的人说是改了脚本o(╯□╰)o。运气也是实力的一部分吧,不过我没有中奖。再接着是为期 15 天的小黑屋封闭式开发快递模块,这也是我第一次感觉到了挫败感,让林师傅帮填了好多坑。期间李开复老师来参观我们公司,我也跑去趁了张合照。同时迎来了陪我渡过短暂岁月的小伙伴虾球。一只可爱的猫咪。现在在新主人家过得很好。

batman && hawkeye

在往后的一段时间,前端部门内部进行了更细致的分工划分,我被划分到了直营业务组,主要负责 batman(客服关系系统) 以及 hawkeye(运维系统)的开发与维护,期间经历了并参与了 batman、hawkeye 的技术栈迭代,从老鹰眼的 reflux 到 redux,以及 batman 网关独立抽离成一个 ts 项目。为了更好的熟悉 react 全家桶的工作流程,五月份的时候在仿照 batman 和 hawkeye 写了个 demo,现在收获了几百的 Star,也算今年的一大收获吧。o(╯□╰)o

下面谈谈今年在做项目的收获和成长:

  • 首先是带我进入了奇妙的 React 技术栈 和 ES6 世界,对相关的知识点进行了实践;
  • 能合理对组件进行拆分;
  • 对 JS 异步流程有了一定认知;
  • 对底层组件的设计有了一定的认知;
  • 对 git 多人合作的流程有了一定的实践;
  • 在遇到不懂的知识时学会进行翻相关文档以及阅读源码;
  • 最后在 npm 上尝试发布了第一个包,并用到了 batman 中;

谈完收获再来谈谈不足:

  • 知识点体系没有梳理成网,比较零散;
  • 知识面广度和深度都还欠缺;
  • 在项目优化的层面没有特别上心;
  • 在和别人交谈时,抓重点能力以及逻辑整合能力有待加强;
  • 效率不足,容易被琐事影响;

高山仰止

分享一位同行的博客中让我感慨良多的话:行远自迩,登高自卑。在写这篇总结前我曾以为这句话是说当你登高的途中看到比你站在更高更远处的人的时候会产生自卑感,现在不经莞尔一笑,风起于青萍之末,浪成于微澜之间,那些让我高高仰望的人不正是有着一颗常人难能企及的大毅力和大心脏吗?而我呢,希望能离那群人能近一点是一点吧。想到现在负责的项目名为 batman,突然谐音想到了 better-man。新年计划做一个让别人靠得住的 better-man。

番外随笔

今年刷了好多动漫,整体偏热血、玄幻类型,也入了 B 站刷了几部小清新动漫,如四月是你的谎言、宝石之国~,和朋友一起刷了几部有意义的电影,比如爱乐之城、寻梦环游记~;另外从 4 月份开始使用网易云音乐来,累计听了七八千首歌吧,基本码代码的时候都会带着耳机~,以英文歌、民谣为主,我的歌单如下

在游戏方面发挥稳定,王者荣耀上了最强王者以及为团队拿下个冠军,不过感觉花了太多时间在上面,浪费了不少原定的计划~,不过也算是青春的成长吧。公司年假即将会去曼巴玩上一段时间,也将算是我第一次出国。

看书方面主要是技术类的,大概阅读了 6、7 本,这个比较尴尬了,看书看得慢而且看过的知识点也不是完全吃透了,新年期待改善。个人产出方面也比较低产,翻译文章和原创文章加起来十几篇~,我把它们集合在了 blog这个项目里。不过也有值得高兴的事,搭建了自己的博客,并且写文章的深度自己感觉有所提高(虽然依然菜)。然后很遗憾的是今年的技术交流会一场也没参加成,怪自己的消息不灵通加上水平的不足。

总结展望篇

这部分是对自己打算要成为一个 better-man 的一个 promise 吧。就结合文章中提到的不足点进行相应的改善(自己想到解决方案的先记下来了),也就是新年的计划。望监督。

problen flag
知识点碎片化 对已有的知识点进行思维脑图的整理,对将来的知识点也采取思维脑图的方式进行学习
知识面广度和深度欠缺 参与到一个千星 Star 的开源项目的改善,提高阅读源码的能力,阅读 6 本技术书籍,阅读 1 本非技术书籍
项目优化方面没有用心 课外学习积累相关方面知识并尝试用到公司项目中
没去成技术交流会 带有目的性地参加 1 到 2 场前端技术交流会
沟通能力不足 避免讲话结巴,加强语言组织能力和逻辑能力,没想清楚问题之前不要轻易回答
效率不足 加强自己的自控能力,规定的时间做规定的事情,做到按时起睡,不晚于 8 点半起床

arrToValue 中的第二个方法

function arrToValue (arr) {
    const tmpArray = Array.prototype.toString
    Array.prototype.toString = function () {
        return this.join(',')
    }
    let result = arr + ''
    Array.prototype.toString = tmpArray
    return result
}

和下面代码返回结果是一样的,有什么区别吗?

console.log(['a', ['b', 'c'], 2, ['d', 'e', 'f', ['m', 'n']], 'g', 3, 4].join(','));

3.生命周期和 diff 算法

该系列文章在实现 cpreact 的同时理顺 React 框架的核心内容

项目地址

生命周期

先来回顾 React 的生命周期,用流程图表示如下:

该流程图比较清晰地呈现了 react 的生命周期。其分为 3 个阶段 —— 生成期,存在期,销毁期。

因为生命周期钩子函数存在于自定义组件中,将之前 _render 函数作些调整如下:

// 原来的 _render 函数,为了将职责拆分得更细,将 virtual dom 转为 real dom 的函数单独抽离出来
function vdomToDom(vdom) {
  if (_.isFunction(vdom.nodeName)) {        // 为了更加方便地书写生命周期逻辑,将解析自定义组件逻辑和一般 html 标签的逻辑分离开
    const component = createComponent(vdom) // 构造组件
    setProps(component)                     // 更改组件 props
    renderComponent(component)              // 渲染组件,将 dom 节点赋值到 component
    return component.base                   // 返回真实 dom
  }
  ...
}

我们可以在 setProps 函数内(渲染前)加入 componentWillMountcomponentWillReceiveProps 方法,setProps 函数如下:

function setProps(component) {
  if (component && component.componentWillMount) {
    component.componentWillMount()
  } else if (component.base && component.componentWillReceiveProps) {
    component.componentWillReceiveProps(component.props) // 后面待实现
  }
}

而后我们在 renderComponent 函数内加入 componentDidMountshouldComponentUpdatecomponentWillUpdatecomponentDidUpdate 方法

function renderComponent(component) {
  if (component.base && component.shouldComponentUpdate) {
    const bool = component.shouldComponentUpdate(component.props, component.state)
    if (!bool && bool !== undefined) {
      return false // shouldComponentUpdate() 返回 false,则生命周期终止
    }
  }
  if (component.base && component.componentWillUpdate) {
    component.componentWillUpdate()
  }

  const rendered = component.render()
  const base = vdomToDom(rendered)

  if (component.base && component.componentDidUpdate) {
    component.componentDidUpdate()
  } else if (component && component.componentDidMount) {
    component.componentDidMount()
  }

  if (component.base && component.base.parentNode) { // setState 进入此逻辑
    component.base.parentNode.replaceChild(base, component.base)
  }

  component.base = base  // 标志符
}

测试生命周期

测试如下用例:

class A extends Component {
  componentWillReceiveProps(props) {
    console.log('componentWillReceiveProps')
  }

  render() {
    return (
      <div>{this.props.count}</div>
    )
  }
}

class B extends Component {
  constructor(props) {
    super(props)
    this.state = {
      count: 1
    }
  }

  componentWillMount() {
    console.log('componentWillMount')
  }

  componentDidMount() {
    console.log('componentDidMount')
  }

  shouldComponentUpdate(nextProps, nextState) {
    console.log('shouldComponentUpdate', nextProps, nextState)
    return true
  }

  componentWillUpdate() {
    console.log('componentWillUpdate')
  }

  componentDidUpdate() {
    console.log('componentDidUpdate')
  }

  click() {
    this.setState({
      count: ++this.state.count
    })
  }

  render() {
    console.log('render')
    return (
      <div>
        <button onClick={this.click.bind(this)}>Click Me!</button>
        <A count={this.state.count} />
      </div>
    )
  }
}

ReactDOM.render(
  <B />,
  document.getElementById('root')
)

页面加载时输出结果如下:

componentWillMount
render
componentDidMount

点击按钮时输出结果如下:

shouldComponentUpdate
componentWillUpdate
render
componentDidUpdate

diff 的实现

在 react 中,diff 实现的思路是将新老 virtual dom 进行比较,将比较后的 patch(补丁)渲染到页面上,从而实现局部刷新;本文借鉴了 preactsimple-react 中的 diff 实现,总体思路是将旧的 dom 节点和新的 virtual dom 节点进行了比较,根据不同的比较类型(文本节点、非文本节点、自定义组件)调用相应的逻辑,从而实现页面的局部渲染。代码总体结构如下:

/**
 * 比较旧的 dom 节点和新的 virtual dom 节点:
 * @param {*} oldDom  旧的 dom 节点
 * @param {*} newVdom 新的 virtual dom 节点
 */
function diff(oldDom, newVdom) {
  ...
  if (_.isString(newVdom)) {
    return diffTextDom(oldDom, newVdom)   // 对比文本 dom 节点
  }

  if (oldDom.nodeName.toLowerCase() !== newVdom.nodeName) {
    diffNotTextDom(oldDom, newVdom)       // 对比非文本 dom 节点
  }

  if (_.isFunction(newVdom.nodeName)) {
    return diffComponent(oldDom, newVdom) // 对比自定义组件
  }

  diffAttribute(oldDom, newVdom)          // 对比属性

  if (newVdom.children.length > 0) {
    diffChild(oldDom, newVdom)            // 遍历对比子节点
  }

  return oldDom
}

下面根据不同比较类型实现相应逻辑。

对比文本节点

首先进行较为简单的文本节点的比较,代码如下:

// 对比文本节点
function diffTextDom(oldDom, newVdom) {
  let dom = oldDom
  if (oldDom && oldDom.nodeType === 3) {  // 如果老节点是文本节点
    if (oldDom.textContent !== newVdom) { // 这里一个细节:textContent/innerHTML/innerText 的区别
      oldDom.textContent = newVdom
    }
  } else {                                // 如果旧 dom 元素不为文本节点
    dom = document.createTextNode(newVdom)
    if (oldDom && oldDom.parentNode) {
      oldDom.parentNode.replaceChild(dom, oldDom)
    }
  }
  return dom
}

对比非文本节点

对比非文本节点,其思路为将同层级的旧节点替换为新节点,代码如下:

// 对比非文本节点
function diffNotTextDom(oldDom, newVdom) {
  const newDom = document.createElement(newVdom.nodeName);
  [...oldDom.childNodes].map(newDom.appendChild) // 将旧节点下的元素添加到新节点下
  if (oldDom && oldDom.parentNode) {
    oldDom.parentNode.replaceChild(oldDom, newDom)
  }
}

对比自定义组件

对比自定义组件的思路为:如果新老组件不同,则直接将新组件替换老组件;如果新老组件相同,则将新组件的 props 赋到老组件上,然后再对获得新 props 前后的老组件做 diff 比较。代码如下:

// 对比自定义组件
function diffComponent(oldDom, newVdom) {
  if (oldDom._component && (oldDom._component.constructor !== newVdom.nodeName)) { // 如果新老组件不同,则直接将新组件替换老组件
    const newDom = vdomToDom(newVdom)
    oldDom._component.parentNode.insertBefore(newDom, oldDom._component)
    oldDom._component.parentNode.removeChild(oldDom._component)
  } else {
    setProps(oldDom._component, newVdom.attributes) // 如果新老组件相同,则将新组件的 props 赋到老组件上
    renderComponent(oldDom._component)              // 对获得新 props 前后的老组件做 diff 比较(renderComponent 中调用了 diff)
  }
}

遍历对比子节点

遍历对比子节点的策略如下:

  1. 比较新旧 dom 元素相同层级相同位置的节点类型,若节点类型不相等,则直接将新节点替换掉旧节点;
  2. 给节点加上 key 属性;

在 cpreact 的代码实现中,1 的目的降低了空间复杂度(避免了更深层次的遍历);2 的目的目前看来是少了一次新老类型的判断消耗。

代码如下:

// 对比子节点
function diffChild(oldDom, newVdom) {
  const keyed = {}
  const children = []
  const oldChildNodes = oldDom.childNodes
  for (let i = 0; i < oldChildNodes.length; i++) {
    if (oldChildNodes[i].key) {
      keyed[oldChildNodes[i].key] = oldChildNodes[i]
    } else { // 如果不存在 key,则优先找到节点类型相同的元素
      children.push(oldChildNodes[i])
    }
  }

  let newChildNodes = newVdom.children
  if (isArray(newVdom.children[0])) { // https://github.com/MuYunyun/cpreact/issues/9
    newChildNodes = newVdom.children[0]
  }

  for (let i = 0; i < newChildNodes.length; i++) {
    let child = null
    if (keyed[newChildNodes[i].key]) {
      child = keyed[newChildNodes[i].key]
      keyed[newChildNodes[i].key] = undefined
    } else { // 对应上面不存在 key 的情形
      // 在新老节点相同位置上寻找相同类型的节点进行比较;如果不满足上述条件则直接将新节点插入;
      if (children[i] && isSameNodeType(children[i], newChildNodes[i])) {
        child = children[i]
        children[i] = undefined
      } else if (children[i] && !isSameNodeType(children[i], newChildNodes[i])) { // 不是相同类型,直接替代掉
        children[i].replaceWith(newChildNodes[i])
        continue
      }
    }

    const result = diff(child, newChildNodes[i])
    // 如果 child 为 null
    if (result === newChildNodes[i]) {
      oldDom.appendChild(vdomToDom(result))
    }
  }
}

测试

在生命周期的小节中,componentWillReceiveProps 方法还未跑通,稍加修改 setProps 函数即可:

/**
 * 更改属性,componentWillMount 和 componentWillReceiveProps 方法
 */
function setProps(component, attributes) {
  if (attributes) {
    component.props = attributes // 这段逻辑对应上文自定义组件比较中新老组件相同时 setProps 的逻辑
  }

  if (component && component.base && component.componentWillReceiveProps) {
    component.componentWillReceiveProps(component.props)
  } else if (component && component.componentWillMount) {
    component.componentWillMount()
  }
}

来测试下生命周期小节中最后的测试用例:

  • 生命周期测试

  • diff 测试

鸣谢

Especially thank simple-react for the guidance function of this library. At the meantime,respect for preact and react

JS 异步系列 —— Promise 札记

Promise 札记

研究 Promise 的动机大体有以下几点:

  • 对其 api 的不熟悉以及对实现机制的好奇;

  • 很多库(比如 fetch)是基于 Promise 封装的,那么要了解这些库的前置条件得先熟悉 Promise;

  • 要了解其它更为高级的异步操作得先熟悉 Promise;

基于这些目的,实践了一个符合 Promise/A+ 规范的 repromise

本札记系列总共三篇文章,作为之前的文章 Node.js 异步异闻录 的拆分和矫正。

Promise/A+ 核心

在实现一个符合 Promise/A+ 规范的 promise 之前,先了解下 Promise/A+ 核心,想更全面地了解可以阅读 Promise/A+规范

  • Promise 操作只会处在 3 种状态的一种:未完成态(pending)、完成态(resolved) 和失败态(rejected);
  • Promise 的状态只会出现从未完成态向完成态或失败态转化;
  • Promise 的状态一旦转化,将不能被更改;

repromise api 食用手册

Promise.resolve()

Promise.resolve() 括号内有 4 种情况

/* 跟 Promise 对象 */
Promise.resolve(Promise.resolve(1))
// Promise {state: "resolved", data: 1, callbackQueue: Array(0)}

/* 跟 thenable 对象 */
var thenable = {
  then: function(resolve, reject) {
    resolve(1)
  }
}

Promise.resolve(thenable)
// Promise {state: "resolved", data: 1, callbackQueue: Array(0)}

/* 普通参数 */
Promise.resolve(1)
// Promise {state: "resolved", data: 1, callbackQueue: Array(0)}

/* 不跟参数 */
Promise.resolve()
// Promise {state: "resolved", data: undefined, callbackQueue: Array(0)}

Promise.reject()

相较于 Promise.resolve(),Promise.reject() 原封不动地返回参数值

Promise.all(arr)

对于 Promise.all(arr) 来说,在参数数组中所有元素都变为决定态后,然后才返回新的 promise。

// 以下 demo,请求两个 url,当两个异步请求返还结果后,再请求第三个 url
const p1 = request(`http://some.url.1`)
const p2 = request(`http://some.url.2`)

Promise.all([p1, p2])
  .then((datas) => { // 此处 datas 为调用 p1, p2 后的结果的数组
    return request(`http://some.url.3?a=${datas[0]}&b=${datas[1]}`)
  })
  .then((data) => {
    console.log(msg)
  })

Promise.race(arr)

对于 Promise.race(arr) 来说,只要参数数组有一个元素变为决定态,便返回新的 promise。

// race 译为竞争,同样是请求两个 url,当且仅当一个请求返还结果后,就请求第三个 url
const p1 = request(`http://some.url.1`)
const p2 = request(`http://some.url.2`)

Promise.race([p1, p2])
  .then((data) => { // 此处 data 取调用 p1, p2 后优先返回的结果
    return request(`http://some.url.3?value=${data}`)
  })
  .then((data) => {
    console.log(data)
  })

Promise.wrap(fn) —— 回调函数转 Promise

通过下面这个案例,提供回调函数 Promise 化的思路。

function foo(a, b, cb) {
  ajax(
    `http://some.url?a=${a}&b=${b}`,
    cb
  )
}

foo(1, 2, function(err, data) {
  if (err) {
    console.log(err)
  } else {
    console.log(data)
  }
})

如上是一个传统回调函数使用案例,只要使用 Promise.wrap() 包裹 foo 函数就对其完成了 promise 化,使用如下:

const promiseFoo = Promise.wrap(foo)

promiseFoo(1, 2)
  .then((data) => {
    console.log(data)
  })
  .catch((err) => {
    console.log(err)
  })

Promise.wrap 的实现逻辑也顺带列出来了:

Promise.wrap = function(fn) {
  return funtion() {
    const args = [].slice.call(arguments)
    return new Promise((resolve, reject) => {
      fn.apply(null, args.concat((err, data) => {
        if (err) {
          reject(err)
        } else {
          resolve(data)
        }
      }))
    })
  }
}

then/catch/done

这几个 api 比较简单,合起来一起带过

Promise.resolve(1)
  .then((data) => {console.log(data)}, (err) => {console.log(err)}) // 链式调用,可以传一个参数(推荐),也可以传两个参数
  .catch((err) => {console.log(err)}) // 捕获链式调用中抛出的错误 || 捕获变为失败态的值
  .done()                             // 能捕获前面链式调用的错误(包括 catch 中),可以传两个参数也可不传

实践过程总结

坑点 1:事件循环

事件循环:同步队列执行完后,在指定时间后再执行异步队列的内容。

之所以要单列事件循环,因为代码的执行顺序与其息息相关,此处用 setTimeout 来模拟事件循环;

下面代码片段中,① 处执行完并不会马上执行 setTimeout() 中的代码(③),而是此时有多少次 then 的调用,就会重新进入 ② 处多少次后,再进入 ③

excuteAsyncCallback(callback, value) {
  const that = this
  setTimeout(function() {
    const res = callback(value) // ③
    that.excuteCallback('fulfilled', res)
  }, 4)
}

then(onResolved, onRejected) {
  const promise = new this.constructor()
  if (this.state !== 'PENDING') {
    const callback = this.state === 'fulfilled' ? onResolved : onRejected
    this.excuteAsyncCallback.call(promise, callback, this.data)              // ①
  } else {
    this.callbackArr.push(new CallbackItem(promise, onResolved, onRejected)) // ②
  }
  return promise
}

坑点 2:this 的指向问题

this.callbackArr.push() 中的 this 指向的是 ‘上一个’ promise,所以类 CallbackItem 中,this.promise 存储的是'下一个' promise(then 对象)。

class Promise {
  ...
  then(onResolved, onRejected) {
    const promise = new this.constructor()
    if (this.state !== 'PENDING') {        // 第一次进入 then,状态是 RESOLVED 或者是 REJECTED
      const callback = this.state === 'fulfilled' ? onResolved : onRejected
      this.excuteAsyncCallback.call(promise, callback, this.data)  // 绑定 this 到 promise
    } else {                               // 从第二次开始以后,进入 then,状态是 PENDING
      this.callbackArr.push(new CallbackItem(promise, onResolved, onRejected)) // 这里的 this 也是指向‘上一个’ promise
    }
    return promise
  }
  ...
}

class CallbackItem {
  constructor(promise, onResolve, onReject) {
    this.promise = promise // 相应地,这里存储的 promise 是来自下一个 then 的
    this.onResolve = typeof(onResolve) === 'function' ? onResolve : (resolve) => {}
    this.onReject = typeof(onRejected) === 'function' ? onRejected : (rejected) => {}
  }
  ...
}

more

实践的更多过程可以参考测试用例。有好的意见欢迎交流。

记踩过坑的前端面试题

根基真的很重要!

异步

var a = true;
setTimeout(function(){
    a = false;
}, 100)
while(a){
    console.log('while执行了')
}

评语: 这是一个很有迷惑性的题目,不少人认为100ms之后,由于a变成了false,所以while就中止了,实际不是这样,因为JS是单线程的,所以进入while循环之后,没有「时间」(线程)去跑定时器了,所以这个代码跑起来是个死循环!

事件循环

function foo() {
    console.log('foo');
    Promise.resolve().then(foo);
}
foo();
function bar() {
    console.log('bar');
    setTimeout(bar);
}
bar();
结果是啥 'foo'、'bar' 各执行一次, 然后陷入 `foo` 的死循环

综合题

function Foo() {
  getName = function () { console.log(1) }
  return this
}
Foo.getName = function () { console.log(2) }
Foo.prototype.getName = function () { console.log(3) }
var getName = function () { console.log(4) }
function getName () { console.log(5) }

Foo.getName()
getName()
Foo().getName()
getName()
new Foo.getName()
new Foo().getName()
new new Foo().getName()
// 2
// 4 考察变量提升
// 1 ※考察作用域
// 1
/* 后面三个考察运算符顺序 */
// 2
// 3
// 3

运算符优先级

用到的优先级: 对象访问 > 带参数的 new > 函数调用 > 不带参数的 new

运算符优先级

再来一个日常经常写的代码片段:

var a = 1
a = a++
a ?

var b = 1
b = ++b
b ?

回过头发现和优先级并无太大关系, 应该和运算符本身有关系。

妙解题

输入: ['a', ['b', 'c'], 2, ['d', 'e', 'f'], 'g', 3, 4]
输出: a, b, c, 2, d, e, f, g, 3, 4

function convert(arr) {
  return arr.toString()
}

正则

题目: 驼峰转化。将 fontSize 转化为 font-size

function turnCamel(literal) {
  return literal.replace(/[A-Z]/g, (match) => {
    return '-' + match.toLowerCase()
  })
}

如果考虑首字母的话, 可以先 slice 一份出来再进行操作。

题目: 字符串中第一个出现一次的字符。eg: go => g; google => l

function find(str) {
  for (let i of str) {
    const reg = new RegExp(i, 'g')
    if (str.match(reg).length === 1) {
      return i
    }
  }
}

题目: 将 1234567 变成 1,234,567, 即千分位标注

function SplitThousand(number) {
  return (number).toLocaleString()
}

正则:

function SplitThousand(str) {
  return str.replace(/\d{1,3}(?=(\d{3})+$)/g, (value) => {
    return value + ','
  })
}

题目,请写出下面的代码执行结果

var str = 'google';
var reg = /o/g;
console.log(reg.test(str))
console.log(reg.test(str))
console.log(reg.test(str))
true
true
false
解答:reg 执行完 test 会发生改变,观察如下:
console.log(reg.test(str), reg.lastIndex) // true  2
console.log(reg.test(str), reg.lastIndex) // true  3
console.log(reg.test(str), reg.lastIndex) // false 0

前端常见数据结构小结

常见数据结构的 JavaScript 实现

前端与数据结构

数据结构在开发中是一种编程**的提炼,无关于用何种语言开发或者是哪种端开发。下列将笔者涉猎到的与前端相关的数据结构案例作如下总结:

数据结构 案例
FILO: 其它数据结构的基础,redux/koa2 中间件机制
队列 FIFO:其它数据结构的基础
链表 React 16 中的 Fiber 的优化
集合 对应 JavaScript 中的 Set
字典 对应 JavaScript 中的 Map
哈希表 一种特殊的字典,可以用来存储加密数据
DOM TREE / HTML TREE / CSS TREE
暂时没遇到,不过里面的 BFS/DFS 蛮常见

使用 React 全家桶搭建一个后台管理系统

使用React技术栈搭建一个后台管理系统最初是为了上手公司的业务,后来发现这个项目还能把平时遇到的有趣的demo给整合进去。此文尝试对相关的技术栈以及如何在该项目中引人Redux进行分析。

项目地址以及局部展示

  • 项目地址

  • 小模块展示:

  • redux在项目中的运用demo展示

项目目录结构

├── build.js                   项目打包后的文件
├── config                     webpack配置文件
│   ├──...
│   ├──webpack.config.dev.js   开发环境配置
│   ├──webpack.config.prod.js  生产环境配置
├── node_modules               node模块目录
├── public
│   └──index.html
├── scripts
│   ├── build.js               打包项目文件
│   ├── start.js               启动项目文件
│   └── test.js                测试项目文件
├── src
│   ├── client                 汇聚(入口)目录
│   ├── common                 核心目录
│   │   ├── actions            redux中的action
│   │   ├── components         通用功能组件
│   │   ├── container          通用样式组件
│   │   ├── images
│   │   ├── pages              页面模块
│   │   ├── reducers           redux中的reducer
│   │   ├── utils              工具类
│   │   │   ├── config.js      通用配置
│   │   │   ├── menu.js        菜单配置
│   │   │   └── ajax.js        ajax模块(日后用到)
│   │   └── routes.js          前端路由
│   └── server                 服务端目录(日后用到)
│       └── controller
├── .gitignore
├── package.json
├── README.md
└── yarn.lock

项目的初始结构和构造原因已罗列如上,由于过些日子会引人ts,所以项目结构必然还会改动,但肯定基于这基本雏形扩展的。

下面对目录结构作以下说明

  • 项目最初始是用 create-react-app 初始化的,create-react-app 是Facebook官方提供的 React 脚手架,也是业界最优秀的 React 应用开发工具之一;
  • client 作为入口目录,到时候可以把第三方中间件也放在此处;
  • container 和 components 存放的都是 react 组件,区别如下表。但是我把和样式有关的组件就放在container中,把和功能有关的模块(比如自己分装的表格组件、弹出输入框组件等)就放到components中,若日后有需要,container 和 component 组件都是可以在 Redux 数据流中的。
container component
目的 如何工作(数据获取,状态更新) 如何显示(样式,布局)
是否在 Redux 数据流中
读取数据 从 Redux 获取 state 从 props 获取数据
修改数据 向 Redux 派发 actions 从 props 调用回调函数
实现方式 向react-redux生成 手写
  • ajax 模块到时候计划用 fetch 封装一个ajax,感觉使用 fetch 还是蛮便利的。
  • server 层就是作为网关层,日后计划用来写 node 的。

技术栈相关

虽然用到的技术栈众多,但是自己也谈不上熟练运用,多半是边查API边用的,所以只罗列些自己用相关的技术栈解决的点;

webpack(2.x)

4月的时候 create-react-app 还是基于 webpack(1.x) 构建的,5月27号升到了webpack(2.6),于是我也进行了 webpack 的版本升级。

按需加载

babel-plugin-import 是一个用于按需加载组件代码和样式的 babel 插件,使用此插件后,在引人 antd 相应模块就能实现按需引人,在config/webpack.config.dev.js 文件中作如下修改:

{
        test: /\.(js|jsx)$/,
        include: paths.appSrc,
        loader: require.resolve('babel-loader'),
        options: {
          plugins: [
            "transform-decorators-legacy",  // 引人 ES7 的装饰器 @
            ['import', [{ libraryName: 'antd', style: true }]],
          ],
          cacheDirectory: true,
        },
      },

引人less

首先引人 less-loader 来加载 less 样式,同时修改 config/webpack.config.dev.js 文件

        test: /\.less$/,
        use: [
          require.resolve('style-loader'),
          require.resolve('css-loader'),
          {
            loader: require.resolve('postcss-loader'),
            options: {
              ident: 'postcss', //https://webpack.js.org/guides/migrating/#complex-options
              plugins: () => [
                require('postcss-flexbugs-fixes'),
                autoprefixer({
                  browsers: [
                    '>1%',
                    'last 4 versions',
                    'Firefox ESR',
                    'not ie < 9', // React doesn't support IE8 anyway
                  ],
                  flexbox: 'no-2009',
                }),
              ],
            },
          },
          {
            loader: require.resolve('less-loader'),
            options: {
              modifyVars: { "@primary-color": "#1DA57A" },  // 这里利用了 less-loader 的 modifyVars 来进行主题配置, 变量和其他配置方式可以参考 [配置主题](https://ant.design/docs/react/customize-theme-cn) 文档。
            },
          },
        ],
      },

一键发布到 gh-pages

用到了 gh-pages ,使用 npm run deploy 一键发布到自己的gh-pages上,姑且把gh-pages当成生产环境吧,所以在修改config/webpack.config.dev.js 文件的同时也要对 config/webpack.config.prod.js 作出一模一样的修改。

引用路径的缩写

alias: {
      'react-native': 'react-native-web',
      components: path.resolve(__dirname, '..') + '/src/common/components',
      container: path.resolve(__dirname, '..') + '/src/common/container',
      images: path.resolve(__dirname, '..') + '/src/common/images',
      pages: path.resolve(__dirname, '..') + '/src/common/pages',
      utils: path.resolve(__dirname, '..') + '/src/common/utils',
      data: path.resolve(__dirname, '..') + '/src/server/data',
      actions: path.resolve(__dirname, '..') + '/src/common/actions',
      reducers: path.resolve(__dirname, '..') + '/src/common/reducers',
    },

配置了引用路径的缩写后,就可以在任意地方如这样引用,比如

import Table from 'components/table'

Antd(2.x)

antd是(蚂蚁金服体验技术部)经过大量的项目实践和总结,沉淀出的一个中台设计语言 Ant Design,使用者包括蚂蚁金服、阿里巴巴、口碑、美团、滴滴等一系列知名公司,而且我从他们的设计理念也学到了很多关于UI、UX的知识。
该项目采用的是antd最新的版本2.10.0,由于2.x的版本和1.x的版本还是相差蛮大的,之前参考的项目(基于1.x)改起来太费劲,所以在组件那块就干脆自己重新封装了一遍。这部分知识点建议多看文档,官方更新还是非常勤快的。

React-router(4.x)

react-router 4.x和2.x的差异又是特别的大,召唤文档,网上基本上都还是2.x的教程,看过文档之后,反正简而言之其就是要让使用者更容易上手。印象最深的是以前嵌套路由写法在4.x中写到同层了。如下示例他们的效果是相同的。

2.x:

<Route path="/" component={App}>
    <Route path="/aaaa" component={AAAA} />
    <Route path="/bbbb" component={BBBB} />
</Route>

4.x:

<Route path="/" component={App} />
<Route path="/aaaa" component={AAAA} />
<Route path="/bbbb" component={BBBB} />

Fetch

fetch 使用比较简单,基本的 promise 用法如下

fetch(url).then(response => response.json())
  .then(data => console.log(data))
  .catch(e => console.log("Oops, error", e))

此外还能这样用

try {
  let response = await fetch(url);
  let data = await response.json();
  console.log(data);
} catch(e) {
  console.log("Oops, error", e);
}

但是其简洁的特点是为了让我们可以自定义其扩展,还是其本身就还不完善呢?我在调用 JSONP 的请求时,发现其不支持对 JSONP 的调用,所幸社区还是很给力地找到了 fetch-jsonp 这个模块,实现了对百度音乐接口调用。fetch-jsonp使用也和 fetch 类似,代码如下

fetchJsonp(url,{method: 'GET'})
  .then((res) =>res.json())
  .then((data) => {})

Redux

使用了redux也已经有段时日了,我对redux的定义就是更好的管理组件的状态,一旦应用的逻辑复杂起来,各种组件状态、界面耦合起来,就容易出岔子,redux就是为了解决这个而诞生的,让我们可以更多地关注UI层,而降低对状态的关注。

画了一幅比较简陋的图来说明 redux 的大致流程,假设首先通过鼠标点击页面上的按钮触发了一个行为(action),这时我们叫了一辆出租车 dispatch() 将这个 action 带到了终点站 store。这时候 store 就会通过 reducer 函数返回一个新的状态 state,从而改变 UI 显示。之前也写了篇深入Redux架构

下面通过把 代办事项 这个demo运用到后台管理系统中来讲解 Redux 在其中的运用。

首先,在入口目录创建 store

const store = createStore(rootReducer)

ReactDOM.render(
  <Provider store={store}>
    { routes }
  </Provider>,
  document.getElementById('root')
);

接着,我使用了 redux-actions 这个模块。使用 redux-actions 的好处是能简化大量对 action 的声明,以及能简化 reducer 的写法。

代办事项的 actions 文件片段(拿展示全部任务、已完成任务、未完成任务的 action 举例):

import { createAction } from 'redux-actions'

export const setVisibility = createAction('SET_VISIBILITY')

没使用 redux-actions 时,actions 写法如下,可看出着实麻烦了不少,

export const setVisibility = (filter) => {
	return {
		type: "SET_VISIBILITY",
		filter
	}
}

相应的代办事项的 reducers 文件片段:

export const setVisibility = handleActions({
  'SET_VISIBILITY'(state, action) {
    return { ...state, ...action.payload}
  }
}, 'SHOW_ALL')

使用 redux-actions 后,只要进行如下调用,reducers文件里的SET_VISIBILITY的 action 就能捕获到SHOW_ALL这个状态。

import { setVisibility } from 'actions/todoList'
@connect(
    (state) => ({
        setVisibility: state.setVisibility, // 这个 setVisibility 是取自 reducers 的
    })
)

dispatch(this.props.dispatch(setVisibility('SHOW_ALL')))

connect 来自 react-redux,这里的 @ 是 ES7里的装饰器的用法,使用它之后又能减少不少的代码量,原来还要写 mapStateToPropsmapDispatchToProps

项目的一些扩展计划

计划在该项目把平时工作、学习中遇到的react案例抽离成demo展现出来,所以以后还会多出一些模块。另外过段时间会在该项目中引人 typescript,如果还有精力的话,可以在这个项目上折腾下网关层。喜欢这个项目的话,点我 Star

MVVM 框架解析之双向绑定

贴士:这篇文章主要是针对 vue 的双向绑定实现,React 中其实并没有双向绑定这一说。

MVVM 框架

近年来前端一个明显的开发趋势就是架构从传统的 MVC 模式向 MVVM 模式迁移。在传统的 MVC 下,当前前端和后端发生数据交互后会刷新整个页面,从而导致比较差的用户体验。因此我们通过 Ajax 的方式和网关 REST API 作通讯,异步的刷新页面的某个区块,来优化和提升体验。

MVVM 框架基本概念

在 MVVM 框架中,View(视图) 和 Model(数据) 是不可以直接通讯的,在它们之间存在着 ViewModel 这个中间介充当着观察者的角色。当用户操作 View(视图),ViewModel 感知到变化,然后通知 Model 发生相应改变;反之当 Model(数据) 发生改变,ViewModel 也能感知到变化,使 View 作出相应更新。这个一来一回的过程就是我们所熟知的双向绑定。

MVVM 框架的应用场景

MVVM 框架的好处显而易见:当前端对数据进行操作的时候,可以通过 Ajax 请求对数据持久化,只需改变 dom 里需要改变的那部分数据内容,而不必刷新整个页面。特别是在移动端,刷新页面的代价太昂贵。虽然有些资源会被缓存,但是页面的 dom、css、js 都会被浏览器重新解析一遍,因此移动端页面通常会被做成 SPA 单页应用。由此在这基础上诞生了很多 MVVM 框架,比如 React.js、Vue.js、Angular.js 等等。

MVVM 框架的简单实现

模拟 Vue 的双向绑定流,实现了一个简单的 MVVM 框架,从上图中可以看出虚线方形中就是之前提到的 ViewModel 中间介层,它充当着观察者的角色。另外可以发现双向绑定流中的 View 到 Model 其实是通过 input 的事件监听函数实现的,如果换成 React(单向绑定流) 的话,它在这一步交给状态管理工具(比如 Redux)来实现。另外双向绑定流中的 Model 到 View 其实各个 MVVM 框架实现的都是大同小异的,都用到的核心方法是 Object.defineProperty(),通过这个方法可以进行数据劫持,当数据发生变化时可以捕捉到相应变化,从而进行后续的处理。

Mvvm(入口文件) 的实现

一般会这样调用 Mvvm 框架

const vm = new Mvvm({
            el: '#app',
            data: {
              title: 'mvvm title',
              name: 'mvvm name'
            },
          })

但是这样子的话,如果要得到 title 属性就要形如 vm.data.title 这样取得,为了让 vm.title 就能获得 title 属性,从而在 Mvvm 的 prototype 上加上一个代理方法,代码如下:

function Mvvm (options) {
  this.data = options.data

  const self = this
  Object.keys(this.data).forEach(key =>
    self.proxyKeys(key)
  )
}

Mvvm.prototype = {
  proxyKeys: function(key) {
    const self = this
    Object.defineProperty(this, key, {
      get: function () { // 这里的 get 和 set 实现了 vm.data.title 和 vm.title 的值同步
        return self.data[key]
      },
      set: function (newValue) {
        self.data[key] = newValue
      }
    })
  }
}

实现了代理方法后,就步入主流程的实现

function Mvvm (options) {
  this.data = options.data
  // ...
  observe(this.data)
  new Compile(options.el, this)
}

observer(观察者) 的实现

observer 的职责是监听 Model(JS 对象) 的变化,最核心的部分就是用到了 Object.defineProperty() 的 get 和 set 方法,当要获取 Model(JS 对象) 的值时,会自动调用 get 方法;当改动了 Model(JS 对象) 的值时,会自动调用 set 方法;从而实现了对数据的劫持,代码如下所示。

let data = {
  number: 0
}

observe(data)

data.number = 1 // 值发生变化

function observe(data) {
  if (!data || typeof(data) !== 'object') {
    return
  }
  const self = this
  Object.keys(data).forEach(key =>
    self.defineReactive(data, key, data[key])
  )
}

function defineReactive(data, key, value) {
  observe(value) // 遍历嵌套对象
  Object.defineProperty(data, key, {
    get: function() {
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        console.log('值发生变化', 'newValue:' + newValue + ' ' + 'oldValue:' + value)
        value = newValue
      }
    }
  })
}

运行代码,可以看到控制台输出 值发生变化 newValue:1 oldValue:0,至此就完成了 observer 的逻辑。

Dep(订阅者数组) 和 watcher(订阅者) 的关系

观测到变化后,我们总要通知给特定的人群,让他们做出相应的处理吧。为了更方便地理解,我们可以把订阅当成是订阅了一个微信公众号,当微信公众号的内容有更新时,那么它会把内容推送(update) 到订阅了它的人。

那么订阅了同个微信公众号的人有成千上万个,那么首先想到的就是要 new Array() 去存放这些人(html 节点)吧。于是就有了如下代码:

// observer.js
function Dep() {
  this.subs = [] // 存放订阅者
}

Dep.prototype = {
  addSub: function(sub) { // 添加订阅者
    this.subs.push(sub)
  },
  notify: function() { // 通知订阅者更新
    this.subs.forEach(function(sub) {
      sub.update()
    })
  }
}

function observe(data) {...}

function defineReactive(data, key, value) {
  var dep = new Dep()
  observe(value) // 遍历嵌套对象
  Object.defineProperty(data, key, {
    get: function() {
      if (Dep.target) { // 往订阅器添加订阅者
        dep.addSub(Dep.target)
      }
      return value
    },
    set: function(newValue) {
      if (value !== newValue) {
        console.log('值发生变化', 'newValue:' + newValue + ' ' + 'oldValue:' + value)
        value = newValue
        dep.notify()
      }
    }
  })
}

初看代码也比较顺畅了,但可能会卡在 Dep.targetsub.update,由此自然而然地将目光移向 watcher,

// watcher.js
function Watcher(vm, exp, cb) {
  this.vm = vm
  this.exp = exp
  this.cb = cb
  this.value = this.get()
}

Watcher.prototype = {
  update: function() {
    this.run()
  },

  run: function() {
    // ...
    if (value !== oldVal) {
      this.cb.call(this.vm, value) // 触发 compile 中的回调
    }
  },

  get: function() {
    Dep.target = this // 缓存自己
    const value = this.vm.data[this.exp] // 强制执行监听器里的 get 函数
    Dep.target = null // 释放自己
    return value
  }
}

从代码中可以看到当构造 Watcher 实例时,会调用 get() 方法,接着重点关注 const value = this.vm.data[this.exp] 这句,前面说了当要获取 Model(JS 对象) 的值时,会自动调用 Object.defineProperty 的 get 方法,也就是当执行完这句的时候,Dep.target 的值传进了 observer.js 中的 Object.defineProperty 的 get 方法中。同时也一目了然地在 Watcher.prototype 中发现了 update 方法,其作用即触发 compile 中绑定的回调来更新界面。至此解释了 Observer 中 Dep.target 和 sub.update 的由来。

来归纳下 Watcher 的作用,其充当了 observer 和 compile 的桥梁。

1 在自身实例化的过程中,往订阅器(dep) 中添加自己

2 当 model 发生变动,dep.notify() 通知时,其能调用自身的 update 函数,并触发 compile 绑定的回调函数实现视图更新

最后再来看下生成 Watcher 实例的 compile.js 文件。

compile(编译) 的实现

首先遍历解析的过程有多次操作 dom 节点,为提高性能和效率,会先将跟节点 el 转换成 fragment(文档碎片) 进行解析编译,解析完成,再将 fragment 添加回原来的真实 dom 节点中。代码如下:

function Compile(el, vm) {
  this.vm = vm
  this.el = document.querySelector(el)
  this.fragment = null
  this.init()
}

Compile.prototype = {
  init: function() {
    if (this.el) {
      this.fragment = this.nodeToFragment(this.el) // 将节点转为 fragment 文档碎片
      this.compileElement(this.fragment) // 对 fragment 进行编译解析
      this.el.appendChild(this.fragment)
    }
  },
  nodeToFragment: function(el) {
    const fragment = document.createDocumentFragment()
    let child = el.firstChild // △ 第一个 firstChild 是 text
    while(child) {
      fragment.appendChild(child)
      child = el.firstChild
    }
    return fragment
  },
  compileElement: function(el) {...},
}

这个简单的 mvvm 框架在对 fragment 编译解析的过程中对 {{}} 文本元素v-on:click 事件指令v-model 指令三种类型进行了相应的处理。

Compile.prototype = {
  init: function() {
    if (this.el) {
      this.fragment = this.nodeToFragment(this.el) // 将节点转为 fragment 文档碎片
      this.compileElement(this.fragment) // 对 fragment 进行编译解析
      this.el.appendChild(this.fragment)
    }
  },
  nodeToFragment: function(el) {...},
  compileElement: function(el) {...},
  compileText: function (node, exp) { // 对文本类型进行处理,将 {{abc}} 替换掉
    const self = this
    const initText = this.vm[exp]
    this.updateText(node, initText) // 初始化
    new Watcher(this.vm, exp, function(value) { // 实例化订阅者
      self.updateText(node, value)
    })
  },

  compileEvent: function (node, vm, exp, dir) { // 对事件指令进行处理
    const eventType = dir.split(':')[1]
    const cb = vm.methods && vm.methods[exp]

    if (eventType && cb) {
      node.addEventListener(eventType, cb.bind(vm), false)
    }
  },

  compileModel: function (node, vm, exp) { // 对 v-model 进行处理
    let val = vm[exp]
    const self = this
    this.modelUpdater(node, val)
    node.addEventListener('input', function (e) {
      const newValue = e.target.value
      self.vm[exp] = newValue // 实现 view 到 model 的绑定
    })
  },
}

在上述代码的 compileTest 函数中看到了期盼已久的 Watcher 实例化,对 Watcher 作用模糊的朋友可以往上回顾下 Watcher 的作用。另外在 compileModel 函数中看到了本文最开始提到的双向绑定流中的 View 到 Model 是借助 input 监听事件变化实现的。

项目地址

本文记录了些阅读 mvvm 框架源码关于双向绑定的心得,并动手实践了一个简版的 mvvm 框架,不足之处在所难免,欢迎指正。

项目演示

项目地址

快排的代码有漏洞

function quickSort(arr) {
  if (arr.length === 0) {
    return []
  }
  const basicValue = arr[Math.floor((arr.length - 1) / 2)] // 随意取,这里取中间
  const left = []
  const right = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] < basicValue) {
      left.push(arr[i])
    }
    if (arr[i] > basicValue) {
      right.push(arr[i])
    }
  }
  return quickSort(left).concat(basicValue, quickSort(right))
}

当arr[i]的值等于basicValue时 这个值就被过滤掉了

React 在服务端渲染的实现

React是最受欢迎的客户端 JavaScript 框架,但你知道吗(可以试试),你可以使用 React 在服务器端进行渲染?

React 在服务端渲染的实现

假设你已经在客户端使用 React 构建了一个事件列表 app。该应用程序使用了您最喜欢的服务器端工具构建的API。几周后,用户告诉您,他们的页面没有显示在 Google 上,发布到 Facebook 时也显示不出来。 这些问题似乎是可以解决的,对吧?

您会发现,要解决这个问题,需要在初始加载时从服务器渲染 React 页面,以便来自搜索引擎和社交媒体网站的爬虫工具可以读取您的标记。有证据表明,Google 有时会执行 javascript 程序并且对生成的内容进行索引,但并不总是的。因此,如果您希望确保与其他服​​务(如Facebook,Twitter)有良好的SEO兼容性,那么始终建议使用服务器端渲染。

在本教程中,我们将逐步介绍服务器端的呈现示例。包括围绕与API交流的React应用程序的共同路障。
在本教程中,我们将逐步向您介绍服务器端的渲染示例。包括围绕着 APIS 交流一些在服务端渲染 React 应用程序的共同障碍。

服务端渲染的优势

可能您的团队谈论到服务端渲染的好处是首先会想到 SEO,但这并不是唯一的潜在好处。

更大的好处如下:服务器端渲染能更快地显示页面。使用服务器端渲染,您的服务器对浏览器进行响应是在您的 HTML 页面可以渲染的时候,因此浏览器可以不用等待所有的 JavaScript 被下载和执行就可以开始渲染。当浏览器下载并执行页面所需的 JavaScript 和其他资源时,不会出现 “白屏” 现象,而 “白屏” 这是在完全有客户端呈现的 React 网站中可能发生的情况。

入门

接下来让我们来看看如何将服务器端渲染添加到一个基本的客户端渲染的使用Babel和Webpack的React应用程序中。我们的应用程序将增加从第三方 API 获取数据的复杂性。我们在GitHub上提供了相关代码,您可以在其中看到完整的示例。

提供的代码中只有一个 React 组件,`hello.js`,这个文件将向 ButterCMS 发出异步请求,并渲染返回的 JSON 列表的博文。ButterCMS 是一个基于API的博客引擎,可供个人使用,因此它非常适合测试现实生活中的用例。启动代码中连接着一个 API token,如果你想使用你自己的 API token 可以使用你的 GitHub 账号登入 ButterCMS

import React from 'react';
import Butter from 'buttercms'

const butter = Butter('b60a008584313ed21803780bc9208557b3b49fbb');

var Hello = React.createClass({
  getInitialState: function() {
    return {loaded: false};
  },
  componentWillMount: function() {
    butter.post.list().then((resp) => {
      this.setState({
        loaded: true,
        resp: resp.data
      })
    });
  },
  render: function() {
    if (this.state.loaded) {
      return (
        <div>
          {this.state.resp.data.map((post) => {
            return (
              <div key={post.slug}>{post.title}</div>
            )
          })}
        </div>
      );
    } else {
      return <div>Loading...</div>;
    }
  }
});

export default Hello;

启动器代码中包含以下内容:

  • package.json - 依赖项
  • Webpack 和 Babel 配置
  • index.html - app 的 HTML 文件
  • index.js - 加载 React 并渲染 Hello 组件

要使应用运行,请先克隆资源库:

git clone ...
cd ..

安装依赖:

npm install

然后启动服务器:

npm run start

浏览器输入 http://localhost:8000 可以看到这个 app: (这里译者进行补充,package.json 里的 start 命令改为如下:"start": webpack-dev-server --watch)

如果您查看渲染页面的源代码,您将看到发送到浏览器的标记只是一个到 JavaScript 文件的链接。这意味着页面的内容不能保证被搜索引擎和社交媒体平台抓取:

增加服务器端渲染

接下来,我们将实现服务器端渲染,以便将完全生成的HTML发送到浏览器。如果要同时查看所有更改,请查看GitHub上的差异

To get started, we'll install Express, a Node.js server side application framework:
开始前,让我们安装 Express,一个 Node.js 的服务器端应用程序框架:

npm install express --save

我们要创建一个渲染我们的 React 组件的服务器:

import express from 'express';
import fs from 'fs';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Hello from './Hello.js';

function handleRender(req, res) {
  // 把 Hello 组件渲染成 HTML 字符串
  const html = ReactDOMServer.renderToString(<Hello />);

  // 加载 index.html 的内容
  fs.readFile('./index.html', 'utf8', function (err, data) {
    if (err) throw err;

    // 把渲染后的 React HTML 插入到 div 中
    const document = data.replace(/<div id="app"><\/div>/, `<div id="app">${html}</div>`);

    // 把响应传回给客户端
    res.send(document);
  });
}

const app = express();

// 服务器使用 static 中间件构建 build 路径
app.use('/build', express.static(path.join(__dirname, 'build')));

// 使用我们的 handleRender 中间件处理服务端请求
app.get('*', handleRender);

// 启动服务器
app.listen(3000);

让我们分解下程序看看发生了什么事情...

handleRender 函数处理所有请求。在文件顶部导入的 ReactDOMServer 类提供了将 React 节点渲染成其初始 HTML 的 renderToString() 方法

ReactDOMServer.renderToString(<Hello />);

这将返回 Hello 组件的 HTML ,我们将其注入到 index.html 的 HTML 中,从而生成服务器上页面的完整 HTML 。

const document = data.replace(/<div id="app"><\/div>/,`<div id="app">${html}</div>`);

To start the server, update the start script in package.json and then run npm run start:
要启动服务器,请更新 `package.json` 中的起始脚本,然后运行 npm run start :

"scripts": {
  "start": "webpack && babel-node server.js"
},

浏览 http://localhost:3000 查看应用程序。瞧!您的页面现在正在从服务器渲染出来了。但是有个问题,
如果您在浏览器中查看页面源码,您会注意到博客文章仍未包含在回复中。这是怎么回事?如果我们在Chrome中打开网络标签,我们会看到客户端上发生API请求。

虽然我们在服务器上渲染了 React 组件,但是 API 请求在 componentWillMount 中异步生成,并且组件在请求完成之前渲染。所以即使我们已经在服务器上完成渲染,但我们只是完成了部分。事实上,React repo 有一个 issue,超过 100 条评论讨论了这个问题和各种解决方法。

在渲染之前获取数据

要解决这个问题,我们需要在渲染 Hello 组件之前确保 API 请求完成。这意味着要使 API 请求跳出 React 的组件渲染循环,并在渲染组件之前获取数据。我们将逐步介绍这一步,但您可以在GitHub上查看完整的差异

To move data fetching before rendering, we'll install react-transmit:
要在渲染之前获取数据,我们需安装 react-transmit

npm install react-transmit --save

React Transmit 给了我们优雅的包装器组件(通常称为“高阶组件”),用于获取在客户端和服务器上工作的数据。

这是我们使用 react-transmit 后的组件的代码:

import React from 'react';
import Butter from 'buttercms'
import Transmit from 'react-transmit';

const butter = Butter('b60a008584313ed21803780bc9208557b3b49fbb');

var Hello = React.createClass({
  render: function() {
    if (this.props.posts) {
      return (
        <div>
          {this.props.posts.data.map((post) => {
            return (
              <div key={post.slug}>{post.title}</div>
            )
          })}
        </div>
      );
    } else {
      return <div>Loading...</div>;
    }
  }
});

export default Transmit.createContainer(Hello, {
  // 必须设定 initiallVariables 和 ftagments ,否则渲染时会报错
  initialVariables: {},
  // 定义的方法名将成为 Transmit props 的名称
  fragments: {
    posts() {
      return butter.post.list().then((resp) => resp.data);
    }
  }
});

我们已经使用 Transmit.createContainer 将我们的组件包装在一个高级组件中,该组件可以用来获取数据。我们在 React 组件中删除了生命周期方法,因为无需两次获取数据。同时我们把 render 方法中的 state 替换成 props,因为 React Transmit 将数据作为 props 传递给组件。

为了确保服务器在渲染之前获取数据,我们导入 Transmit 并使用 Transmit.renderToString 而不是 ReactDOM.renderToString 方法

import express from 'express';
import fs from 'fs';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import Hello from './Hello.js';
import Transmit from 'react-transmit';

function handleRender(req, res) {
  Transmit.renderToString(Hello).then(({reactString, reactData}) => {
    fs.readFile('./index.html', 'utf8', function (err, data) {
      if (err) throw err;

      const document = data.replace(/<div id="app"><\/div>/, `<div id="app">${reactString}</div>`);
      const output = Transmit.injectIntoMarkup(document, reactData, ['/build/client.js']);

      res.send(document);
    });
  });
}

const app = express();

// 服务器使用 static 中间件构建 build 路径
app.use('/build', express.static(path.join(__dirname, 'build')));

// 使用我们的 handleRender 中间件处理服务端请求
app.get('*', handleRender);

// 启动服务器
app.listen(3000);

重新启动服务器浏览到 http://localhost:3000。查看页面源代码,您将看到该页面现在完全呈现在服务器上!

更进一步

我们做到了!在服务器上使用 React 可能很棘手,尤其是从 API 获取数据时。幸运的是,React社区正在蓬勃发展,并创造了许多有用的工具。如果您对构建在客户端和服务器上渲染的大型 React 应用程序的框架感兴趣,请查看 Walmart Labs 的 ElectrodeNext.js。或者如果要在 Ruby 中渲染 React ,请查看 AirBnB 的 Hypernova

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.