MoreRSS

site iconCRIMX修改

沙拉查词作者。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

CRIMX的 RSS 预览

如何在 Sass 中方便引用祖先选择器

2020-07-14 21:41:07

双亲选择器

在 Sass 中双亲选择器(Parent Selector 有时也叫父选择器) & 是一个十分有用且常用的选择器,它可以复用外部选择器的名字,从而更轻松地实现多重样式编写。

.btn {
  background: transparent;

  &:hover {
    background: grey;
  }
}

会输出

.btn {
  background: transparent;
}
.btn:hover {
  background: grey;
}

祖先选择器

有时候我们遇到这样一种模式,如主题样式,在元素根处可能有 .dark-theme 来说明目前处于黑暗模式;或者使用 Modernizr 检测浏览器特性,在根元素会根据环境添加相应 class 表示特性支持情况。这时候我们写样式可能就需要拆分开来写。

.btn {
  background: transparent;

  &:hover {
    background: grey;
  }
}

.dark-theme .btn {  background: linear-gradient(cornflowerblue, rebeccapurple);}

这里有两点不太舒服的地方:

  1. 处理同个逻辑的样式需要拆开写。
  2. 与祖先选择器名耦合,不方便修改。

我们来看看如何解决这两个痛点。

@at-root

在 Sass 中有一个 at 规则叫 @at-root,它可以跳出当前嵌套直接在文档根输出内容。

.btn {
  background: transparent;

  &:hover {
    background: grey;
  }
  
  @at-root .dark-theme & {    background: linear-gradient(cornflowerblue, rebeccapurple);  }}

会输出

.btn {
  background: transparent;
}
.btn:hover {
  background: grey;
}
.dark-theme .btn {
  background: linear-gradient(cornflowerblue, rebeccapurple);
}

但这里依然没有解决祖先选择器名耦合的问题,于是我们进一步抽象。

Mixin

将以上用法封装为 mixin 即可达到复用。

@mixin dark-theme {
  @at-root .dark-theme & {
    @content;
  }
}

.btn {
  background: transparent;

  &:hover {
    background: grey;
  }
  
  @include dark-theme {
    background-image: linear-gradient(cornflowerblue, rebeccapurple);
  }
}

支持修饰符

一些过渡库,如 Vue transition 和 React Transition Group 会设置一系列的类型名,如 .fade-enter.fade-exit,在 Sass 中我们可以直接拼接 &-enter 进行复用,现在让我们的 mixin 也支持:

@mixin dark-theme($modifiers...) {
  @if length($modifiers) > 0 {
    @each $modifier in $modifiers {
      @at-root .dark-theme &#{$modifier} {
        @content;
      }
    }
  } @else {
    @at-root .dark-theme & {
      @content;
    }
  }
}

.btn {
  background: transparent;

  &:hover {
    background: grey;
  }
  
  @include dark-theme {
    background: linear-gradient(cornflowerblue, rebeccapurple);
  }
  
  @include dark-theme(-enter) {
    background: cornflowerblue;
  }
  
  @include dark-theme(-enter-active, -exit) {
    background: rebeccapurple;
  }
}

输出

.btn {
  background: transparent;
}
.btn:hover {
  background: grey;
}
.dark-theme .btn {
  background: linear-gradient(cornflowerblue, rebeccapurple);
}

.dark-theme .btn-enter {
  background: cornflowerblue;
}

.dark-theme .btn-enter-active {
  background: rebeccapurple;
}

.dark-theme .btn-exit {
  background: rebeccapurple;
}

可以看到 @include dark-theme(-enter-active, -exit) 在多个修饰符的情况下生成了单独的重复内容。

要去掉重复我们可以直接拼接选择器。

@mixin dark-theme($modifiers...) {
  @if length($modifiers) > 0 {
    $selectors: ();
    @each $modifier in $modifiers {
      $selectors: append(
        $selectors,
        #{".dard-theme "}#{&}#{$modifier},
        comma
      );
    }
    @at-root #{$selectors} {
      @content;
    }
  } @else {
    @at-root .dark-theme & {
      @content;
    }
  }
}

输出

.btn {
  background: transparent;
}
.btn:hover {
  background: grey;
}
.dark-theme .btn {
  background: linear-gradient(cornflowerblue, rebeccapurple);
}

.dard-theme .btn-enter {
  background: cornflowerblue;
}

.dard-theme .btn-enter-active, .dard-theme .btn-exit {  background: rebeccapurple;}

抽象出通用 Mixin

最后我们将这个模式再进一步抽象出来,成为通用的 at-root mixin。

@mixin at-root($ancestor, $modifiers...) {
  @if length($modifiers) > 0 {
    $selectors: ();
    @each $modifier in $modifiers {
      $selectors: append(
        $selectors,
        #{$ancestor}#{" "}#{&}#{$modifier},
        comma
      );
    }
    @at-root #{$selectors} {
      @content;
    }
  } @else {
    @at-root #{$ancestor} & {
      @content;
    }
  }
}

现在 dark-theme 可以这么定义。

@mixin dark-theme($modifiers...) {
  @include at-root('.dark-mode', $modifiers...) {
    @content;
  }
}

最后

通过封装 mixin 我们可以方便地在规则内部直接引用祖先选择器定义规则,同时摆脱与祖先类型名的耦合,使到代码可以灵活应对变更。

Web Extension Live Reloading

2020-07-09 02:58:12

TL;DR

Use neutrino-webextension which works out of the box.

Read on if you are interested in the theory behind the scene.

Reload API

There is a browser.management API which is used by many extension-livereload extensions. But looks like it does not work when manifest.json changes.

Instead we use another API browser.runtime.reload() which reloads the extension itself.

Reload Timing

How do we know when to reload? It should happens after file changes. If using bundler there usually be hooks for performing jobs after bundling. Otherwise take a look at fs.watch or node-watch.

Reload Signal

How does the extension know when to reload itself? It is not ideal using WebSocket or extension messaging API which involves native setups. Instead we try to leverage the browser extension API itself.

The idea is that the extension monitors web requests for a special url. Whenever the browser requests this url the extension gets notified and performs reloading logic.

Project Structure

This is an example project structure for the sake of this post.

project/
├── livereload
│   ├── background.js
│   ├── livereload.html
│   └── livereload.js
├── src
│   ├── background
│   │   └── index.js
│   └── popup
│       ├── index.html
│       └── index.js
└── manifest.json

Web Request Redirecting

First we need to be able to redirect web requests.

manifest.json
{
  "background": {
    "persistent": true,
    "scripts": [
      "livereload/background.js",
      "src/background/index.js"
    ]
  },
  "permissions": [
    "*://neutrino-webextension.reloads/*",
    "webRequest",
    "webRequestBlocking"
  ],
  "web_accessible_resources": [
    "livereload/*"
  ]
}

http://neutrino-webextension.reloads is the special url that we are going to monitor.

livereload/background.js
const b = typeof browser === 'undefined' ? chrome : browser

b.webRequest.onBeforeRequest.addListener(
  () => ({ redirectUrl: b.runtime.getURL('livereload/livereload.html') }),
  {
    urls: ['*://neutrino-webextension.reloads/*'],
    types: ['main_frame']
  },
  ['blocking']
)

It will redirect the request to livereload/livereload.html.

Dummy Page

We first send a message to background, then close the page immediately.

livereload/livereload.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Live Reload</title>
</head>
<body>
  <script src="./livereload.js"></script>
</body>
</html>

Script has to be in separate file.

livereload/livereload.js
const b = typeof browser === 'undefined' ? chrome : browser

b.runtime.sendMessage('_neutrino-webextension.reloads_')

if (window.history.length <= 1) {
  window.close()
} else {
  history.back()
}

Reload Extension

In background we listen to the messages and perform reloading.

livereload/background.js
const b = typeof browser === 'undefined' ? chrome : browser

b.webRequest.onBeforeRequest.addListener(
  () => ({ redirectUrl: b.runtime.getURL('livereload/livereload.html') }),
  {
    urls: ['*://neutrino-webextension.reloads/*'],
    types: ['main_frame']
  },
  ['blocking']
)

b.runtime.onMessage.addListener(message => {  if (message === '_neutrino-webextension.reloads_') {    b.runtime.reload()  }})

Browsing History

So far so good! Except there is one tiny issue. The redirection will leave browsing histories in the browser. Let's remove it!

manifest.json
{
  "background": {
    "persistent": true,
    "scripts": [
      "livereload/background.js",
      "src/background/index.js"
    ]
  },
  "permissions": [
    "browsingData",    "*://neutrino-webextension.reloads/*",
    "webRequest",
    "webRequestBlocking"
  ],
  "web_accessible_resources": [
    "livereload/*"
  ]
}

Remove before reloading.

livereload/background.js
const b = typeof browser === 'undefined' ? chrome : browser

b.webRequest.onBeforeRequest.addListener(
  () => ({ redirectUrl: b.runtime.getURL('livereload/livereload.html') }),
  {
    urls: ['*://neutrino-webextension.reloads/*'],
    types: ['main_frame']
  },
  ['blocking']
)

b.runtime.onMessage.addListener(message => {
  if (message === '_neutrino-webextension.reloads_') {
    b.browsingData.remove(      {        hostnames: [          'neutrino-webextension.reloads'        ],        originTypes: {          unprotectedWeb: true,          protectedWeb: true        },        since: Date.now() - 2000      },      { history: true }    )    b.browsingData.remove(      {        originTypes: {          extension: true        },        since: Date.now() - 2000      },      { history: true }    )    
    b.runtime.reload()
  }
})

This will remove the history of the special url and the livereload.html.

Open Browser

To open the brower with the special url:

npm install --save-dev open

After file changes, call

open('http://neutrino-webextension.reloads')

// specify browser
open('http://neutrino-webextension.reloads', { app: 'firefox' })

// with arguemnts
open(
  'http://neutrino-webextension.reloads',
  {
    app: ['google-chrome', '--profile-directory=Profile 1']
  }
)

The extension should recognise the request and reload itself.

Conclusion

Even though it works, this is still a lot of work to setup if implementing manually. It is recommended use a preset like neutrino-webextension which is battery included.

如何测试 React 并发模式安全

2020-07-01 00:20:16

自宣布一年多过去 React 并发模式(Concurrent Mode)依然在实验阶段,但早期生态已悄然在形成。Concurrent Mode 这个词越来越频繁出现各种 React 库的介绍和讨论中。作为库开发者或者正打算开发 React 库的朋友,现在开始测试并发模式安全能避免日后可能出现的许多隐性问题,同时这也是一个很好的招牌。

注意:本文内容比较前沿,请留意文章的时限,以下的内容随时均可能发生改变。

使用 React 副本测试

目前只有 @experimental 版本的 React 才支持开启并发模式,考虑到稳定性,我们更希望尽量用稳定版 React 测试其它功能,只用实验版 React 测试并发模式下的功能。

yarn add --dev experimental_react@npm:react@experimental experimental_react-dom@npm:react-dom@experimental experimental_react-test-renderer@npm:react-test-renderer@experimental

如此我们安装实验版本并加上了 experimental_ 前缀的别名。选择前缀而不是后缀是为了方便日后统一去除。

设置 Jest Mocks

React 通过 scheduler 这个模块来进行调度,并提供了 jest-mock-scheduler 来在测试时 mock 掉。目前 jest-mock-scheduler 仅仅是导出了 scheduler/unstable_mock.js,所以不装也可以,React 内部也是直接引用 scheduler/unstable_mock.js,但考虑到未来兼容,还是建议安装 jest-mock-scheduler

yarn add --dev jest-mock-scheduler

测试文件中:

let Scheduler
let React
let ReactTestRenderer
let act
let MyLib

describe('Concurrent Mode', () => {
  beforeEach(() => {
    jest.resetModules()
    jest.mock('scheduler', () => require('jest-mock-scheduler'))
    jest.mock('react', () => require('experimental_react'))
    jest.mock('react-dom', () => require('experimental_react-dom'))
    jest.mock('react-test-renderer', () => require('experimental_react-test-renderer'))

    MyLib = require('../src')
    React = require('react')
    ReactTestRenderer = require('react-test-renderer')
    Scheduler = require('scheduler')

    act = ReactTestRenderer.act
  })
})

如果用 TypeScript 写测试,那么

let Scheduler: import('./utils').Scheduler
let React: typeof import('react')
let ReactTestRenderer: typeof import('react-test-renderer')
let act: typeof import('react-test-renderer').act
let MyLib: typeof import('../src')

其中 scheduler mock 的类型目前先手动补上,见这里

自定义断言

React 内部使用了许多自定义断言,为了减少使用难度,这里我们参考同样的方式扩展 Jest expect

jest.config.js 中添加 setupFilesAfterEnv 指定配置文件,如 setupFilesAfterEnv: [require.resolve('./scripts/jest-setup.js')],自定义断言参考这里

如果用 TypeScript 写测试,那么还需要添加 expect-extend.d.ts,参考这里

测试调度

Scheduler mock 掉之后多了许多控制调度的方法。基本逻辑是默认所有调度都只会累积而不处理,通过手动 flush 或者 act 清理。

通过 yeildValue 记录锚值,然后 flush 的时候可以选择只清理到特定锚值的地方,相当于打断点。在断点处我们可以做各种额外的处理以测试我们的库是否会出现异常。

测试断裂

并发模式下的一个常见问题是状态出现断裂(tearing)。这通常出现在依赖外部模块或者 ref 管理状态。当组件渲染暂停时,如果外部状态发生了变化,该组件恢复渲染后将使用新的值进行渲染,但其它组件却可能在之前已经用了旧的值渲染,故出现了断裂。

要测试我们的库会不会产生断裂现象,我们可以在组件渲染结束前打一个点,到断点后触发外部状态变化,然后检查组件状态是否准确。

如一个捏造的监听任意 input 元素值的 hook,

const useInputValue = input => {
  const [value, setValue] = React.useState('A')

  React.useEffect(() => {
    const callback = event => {
      setValue(event.currentTarget.value)
    }
    input.addEventListener('change', callback)
    return () => input.removeEventListener('change', callback)
  }, [input])

  return value
}

为了测试这个 hook 会不会产生断裂,我们设置两个组件监听同个数据源,中断一个组件的渲染,同时数据源产生新值,再恢复组件渲染并对比两个组件结果是否相同。

it('should should not tear', () => {
  const input = document.createElement('input')

  const emit = value => {
    input.value = value
    input.dispatchEvent(new Event('change'))
  }

  const Test = ({ id }) => {
    const value = useInputValue(input)
    // 打点
    Scheduler.unstable_yieldValue(`render:${id}:${value}`)
    return value
  }

  act(() => {
    ReactTestRenderer.create(
      <React.Fragment>
        <Test id="first" />
        <Test id="second" />
      </React.Fragment>,
      // 启用并发模式
      { unstable_isConcurrent: true }
    )

    // 初次渲染
    expect(Scheduler).toFlushAndYield(['render:first:A', 'render:second:A'])

    // 检查正常修改渲染
    emit('B')
    expect(Scheduler).toFlushAndYield(['render:first:B', 'render:second:B'])

    // 这次渲染到第一个组件后停止
    emit('C')
    expect(Scheduler).toFlushAndYieldThrough(['render:first:C'])

    // 同时产生新值
    emit('D')
    expect(Scheduler).toFlushAndYield([
      'render:second:C',
      'render:first:D',
      'render:second:D'
    ])
  })
})

最后两者均渲染 D,故使用该 hook 没有断裂问题。

自定义 Webpack Target

2020-03-30 01:04:45

问题

由于浏览器扩展有特殊的权限限制,许多前端的开发工具都无法直接派上用场,如之前我解决了热更新分块自动填写到清单的问题。现在我们继续突破下个影响性能的问题:动态加载分块。

Webpack 支持 import() 自动分块并异步加载,这对于大型应用来说是非常有用的功能。虽然浏览器扩展的源文件都在本地,但对于大型应用来说静态加载依然会浪费了不少内存。那么为什么浏览器扩展不支持异步加载呢?这就需要理解 Webpack 是怎么处理的。

(如果只关心如何在浏览器扩展中使用,本文的内容已封装为 webpack-target-webextension 库。)

JSONP

当我们指定(或默认) Webpack Target 为 web 的时候,Webpack runtime 会以 JSONP 方式来加载异步块。那么什么是 JSONP?

JSONP 常用于跨域动态获取数据。如 a.comb.com 请求数据,

  • 首先生成一个回调函数名,如 myCallback
  • 创建全局函数 myCallback 实现加载数据的逻辑;
  • myCallback 作为参数构造请求链接,如 https://b.com/data?callback=myCallback
  • 通过支持跨域的 <script> 标签发起请求,<script src="https://b.com/data?callback=myCallback"></script>
  • 服务器将数据包裹到回调中返回, myCallback(...)
  • 浏览器加载脚本,myCallback 的逻辑被执行。

沙箱

这种方式为什么在浏览器扩展中会失效呢?我们都知道一些浏览器扩展可以对用户的网页进行修改,如美化或者去广告。这些修改是通过一种叫 content script 类型的脚本实现。每个 content script 可以在作者指定的时机被植入到页面上。虽然 content script 可以修改 DOM,但是 content script 本身是运行在隔离的沙箱环境中的。这个环境可以让 content script 访问部分浏览器扩展 API。

所以当 Webpack 以 JSONP 方式加载异步块的时候,<script> 中的回调会在用户的脚本环境中执行,而扩展环境中的接收回调只能默默等待到超时。

不如来真的

主流浏览器早早就支持了原生的 import() ,那么有没有可能,我们不让 Webpack 生成 JSONP 而直接使用原生的 import()? CRIMX 说 yes!

在 Webpack 中,模块加载的逻辑通过 target 设置来调整。Webpack 4 中预设了几种常见的 target:

Option Description
async-node 用于类 Node.js 环境
electron-main 用于 Electron 主进程
electron-renderer 用于 Electron 渲染进程
electron-preload 用于 Electron 渲染进程
node 用于类 Node.js 环境
node-webkit 用于 NWebKit 环境
web 用于类浏览器环境
webworker 用于 WebWorker

很可惜这几种都不支持原生 import(),也不适用浏览器扩展。在 Webpack 5 的预览中明确提到了对 es2015 的支持,同时提供了新的 module 设置。但是离 Webpack 5 正式发布以及生态跟上可能还有一段时间。

最后 target 还支持传入函数以自行实现逻辑。尽管 Webpack 的源码不是很好读,最后还是决定挑战一下,自定义实现一个针对浏览器扩展的 target

其实很简单

首先通过文档找到判断上面预设环境的位置。通过参考 web 的配置可以找到 JSONP 的实现在 JsonpMainTemplatePlugin.js 中。

其中异步块的加载分了三种方式,正常的,预加载的以及预读取的,对应 <script><link>preloadprefetch。全部改成 import() 即可。

其中注意计算块的路径,由于在 content script 中相对路径会根据当前页面计算,而我们需要根据扩展根来算路径。所以函数 jsonpScriptSrc 改为

if (needChunkOnDemandLoadingCode(chunk)) {
  extraCode.push(
    '',
    '// script path function',
    'function webextScriptSrc(chunkId) {',
    Template.indent([
      `var publicPath = ${mainTemplate.requireFn}.p`,
      `var scriptSrcPath = publicPath + ${getScriptSrcPath(
        hash,
        chunk,
        'chunkId'
      )};`,
      `if (!publicPath || !publicPath.includes('://')) {
        return (typeof chrome === 'undefined' ? browser : chrome).runtime.getURL(
          scriptSrcPath
        );
      } else {
        return scriptSrcPath;
      }`
    ]),
    '}'
  )
}

从而利用 runtime.getURL 来计算扩展资源路径。

小坑

可以通过 publicPath 来控制根路径。

注意去除 @babel/plugin-syntax-dynamic-import 等插件以免 import() 被转换掉。

Webpack 一些设置的默认值依赖 target 来判断,所以需要手动设置:

module.exports = {
  resolve: {
    mainFields: ['browser', 'module', 'main'],
    aliasFields: ['browser']
  },
  output: {
    globalObject: 'window'
  }
}

完整修改见这里

RxJS Hooks and Suspense: The Ultimate Guide

2020-02-26 01:58:01

(This post is also on medium)

Why Hooks

Stateful logic is unavoidable in any React project. In early days we used the state property in Class-Components to hold stateful values.

"This" isn't the way

But quickly we realized that it is prone to lose track of states in "this" way. So we divided Components into stateful(smart) Components and stateless(dumb) Components. Stateful logic is delegated to parent stateful Components to keep most Components stateless.

This does not solve the issue, just makes it less painful.

Time travelling

Then came the age of Redux(and MobX etc.). We started to put states into central stores which can be tracked with devtools and stuff.

This does not solve the issue, just delegates it to outside stores.

Introducing stores is acceptable for a full project but would be too bloated for developing reusable stateful Components.

Get on the Hook

React Hooks fills this gap by offering a mechanism that connects side-effects separately within the Component.

For stateful logic it is like connecting to many mini-stores within the Component. Side-effect code with hooks is compact, reusable and testable.

Hooks is an attempt to solve the issue. It is delicate and not perfect but it is the best we have so far.

For more about hooks see the React Docs.

Why RxJS in Hooks

Since React hooks opens a door of reusing side-effect logic within Components, it is tempting to reuse complicated asynchronous logic like remote data fetching, intricate animation or device input sequence interpretation.

One of the most popular ways to manage complicated asynchronous logic is Reactive Programming, a language-independent declarative programming paradigm concerned with data streams and the propagation of change. RxJS, part of the ReactiveX(Reactive Extensions), is a JavaScript implementation of reactive programming.

There are also libraries that focus only on a few specific asynchronous scenarios, like swr for remote data fetching. This is like comparing Redux Saga with Redux Observable. The knowledge you gain from learning how to use these libraries is not as transferable as RxJS and Reactive Programming.

Yes there is a learning curve on RxJS but that is mostly a one-time conceptual thing. Don't be scared by the number of RxJS opertators. You most likely only need a few of them. Also see the Operator Decision Tree.

Observable Hooks

We first tried rxjs-hooks but quickly encountered some tricky TypeScript issues. We also think the useEventCallback is taking too much responsibilities which is a performance issue that is hard to fix due to rules of hooks.

Unfortunately the project is not actively developed as the team has shifted focus to the redux-observable-like ayanami project.

Ultimately we rethought the whole integration, redesigned API from the ground up and created observable-hooks for connecting RxJS Observable to React Components.

A simple example(more on the docs):

import React from 'react'
import { useObservableState } from 'observable-hooks'
import { timer } from 'rxjs'
import { switchMap, mapTo, startWith } from 'rxjs/operators'

const App = () => {
  const [isTyping, updateIsTyping] = useObservableState(
    event$ => event$.pipe(
      switchMap(() =>
        timer(1000).pipe(
          mapTo(false),
          startWith(true)
        )
      )
    ),
    false
  )

  return (
    <div>
      <input type="text" onKeyDown={updateIsTyping} />
      <p>{isTyping ? 'Good you are typing.' : 'Why stop typing?'}</p>
    </div>
  )
}

observable-hooks

By decoupling states, events and Observables it no longer makes unused resources run idle.

Logic lives in pure function which improves reusability and testability.

See the docs for more about core concepts and API.

Pomodoro Timer Example:

Suspense

With the experimental React Suspense asynchronous resources can be read declaratively like it has already been resolved.

Since Suspense is just a mechanism it is possible to convert Observables into Suspense compatible resources (benefits of observable as data source).

Observable-hooks offers ObservableResource to do the trick.

// api.js
import { ObservableResource } from 'observable-hooks'

const postResource$$ = new Subject()

export const postsResource = new ObservableResource(postResource$$.pipe(
  switchMap(id => fakePostsXHR(id))
))

export function fetchPosts(id) {
  postResource$$.next(id)
}

Resources are consumed with useObservableSuspense.

// App.jsx
import { useObservableSuspense } from 'observable-hooks'

import { postsResource, fetchPosts } from './api'

fetchPosts('crimx')

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading posts...</h1>}>
      <ProfileTimeline />
    </Suspense>
  )
}

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = useObservableSuspense(postsResource)
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  )
}

Conclusion

The API of observable-hooks is really simple and flexible. Folks who love both React and RxJS I highly recommend you give it a try.

What do you think? Please let us know by leaving a comment below!

TypeScript 集合转换为交集

2020-01-16 17:30:25

Object Assign

开始之前我们先来看看 libObject.assign 的类型是如何定义的

assign<T, U>(target: T, source: U): T & U;
assign<T, U, V>(target: T, source1: U, source2: V): T & U & V;
assign<T, U, V, W>(target: T, source1: U, source2: V, source3: W): T & U & V & W;
assign(target: object, ...sources: any[]): any;

意不意外,惊不惊喜。硬编码重载了三种情况,那么超过四个对象之后我们只能得到 any

这是由于 TypeScript 的局限性导致的,当然现在 TypeScript 也没有正式解决这个问题,但我们其实已经可以通过 2.8 以后引入的一些特性来 hack 掉这个问题。

Intersection From Union

type MapTopParameter<U> = U extends any ? (arg: U) => void : never
type IntersectionFromUnion<U> =
  MapTopParameter<U> extends (arg: infer T) => void ? T : never

type A = { a: 1 }
type B = { b: 2 }
type C = { c: 2 }

// $ExpectType A & B & C
type Result = IntersectionFromUnion<A | B | C>

要理解这个 hack 需要明白 TypeScript 2.8 引入的两个特性:条件类型(Conditional Types)以及条件类型推导(Type inference in conditional types)。

条件类型

条件类型可以让我们对类型进行三元运算,根据不同情况返回不同类型

T extends U ? X : Y

但与普通编程语言的三元运算不一样,TypeScript 中还有这么一个特性,叫分布式条件类型(Distributive Conditional Types)。

T 是一个集合(Union)的时候,三元运算是对集合中每个元素进行运算,而不是对 T 这个整体进行运算。可以类比为数组中的 map,对集合进行映射,这相当于往类型系统中加入了遍历功能,并且结合 never 也得到了 filter 的功能。

所以现在 TypeScript 类型系统中有了变量(泛型)、条件控制、循环控制,越来越像一门编程语言了……

利用这个特性,我们看回

type MapTopParameter<U> = U extends any ? (arg: U) => void : never

这里是将集合 U 映射为另外一个以 U 元素为参数的函数集合。

// $ExpectType ((arg: number) => void) | ((arg: 'blog.crimx.com') => void)
type Result = MapTopParameter<number | 'blog.crimx.com'>

这么做有什么用呢,我们接着看。

条件类型推导

条件类型推导其实是一种简单的模式匹配,可以类比为正则表达式。

type ReturnType<T> = T extends (...args: any[]) => infer R ? R : any;

这里可以理解为,我们给出了 (...args: any[]) => infer R 这个模板,然后让 T 套这个模板,收集 R 的部分,如这里是收集函数返回的类型。

再结合前面的分布式条件类型,如果 T 是一个集合,那么最后收集的则是各项返回值的集合。

// $ExpectType number | 'blog.crimx.com'
type Result = ReturnType<((arg: string) => number) | (() => 'blog.crimx.com')>

有趣的地方来了,如果我们推导的是函数的参数呢?

type Parameter<T> = T extends (arg: infer P) => any ? P : any

其实也是一样,最后我们会得到一个参数的集合。

type A = { a: number }
type B = { b: 'blog.crimx.com' }

// $ExpectType A | B
type Result = Parameter<((arg: A) => void) | ((arg: B) => void)>

但是!如果我们能想办法阻止这个分布式条件类型,让 T 集合作为一个整体去判断, 这时候表达的是 T 集合中的每一个元素都可以作为 (arg: infer P) => any 的参数使用,也就是说 P 应该是 T 中每个元素的父类,故 P 最后会得到 T 所有元素的交集(Intersection)。

怎么才能达到这个效果呢?

无封装类型参数

让一个类型成为分布式条件类型其实有一个前提,这个类型必须是无封装的类型参数(naked type parameter),即这个类型推导完成后不能是依然包在其它类型中。

所以我们简单修改一下

type Parameter<T> = [T] extends [(arg: infer P) => any] ? P : any

type A = { a: number }
type B = { b: 'saladict.app' }

// $ExpectType A & B
type Result = Parameter<((arg: A) => void) | ((arg: B) => void)>

成功得到交集了!

当然对于前面的实现我们无需这么做,因为 MapTopParameter 已经是一层封装。

type MapTopParameter<U> = U extends any ? (arg: U) => void : never
type IntersectionFromUnion<U> =
  MapTopParameter<U> extends (arg: infer T) => void ? T : never

或者写在一起(略丑)

export type IntersectionFromUnion<TUnion> = (TUnion extends any
  ? (arg: TUnion) => void
  : never) extends (arg: infer TArg) => void
    ? TArg
    : never

元组转集合

这是一个很多人不知道的小特性,将一个元组(tuple)转换为集合。

type tuple = [boolean, 'blog.crimx.com', number]

// $ExpectType number | boolean | "blog.crimx.com"
type union = tuple[number]

现代版 Object Assign

最后结合 TypeScript 3.0 加入的 rest 参数,我们定义一个现代版 Object.assign

function objectAssign<TTarget extends object, TSources extends any[]>(
  target: TTarget,
  ...sources: TSources
): IntersectionFromUnion<TTarget | TSources[number]> {
  return Object.assign(target, ...sources)
}

const a = objectAssign({ a: 1 }, { b: 2 }, { c: 3 })
// $ExpectType { a: number } & { b: number } & { c: number }
type A = typeof a

最后

通过本文例子的讲解希望能帮助大家深入了解 TypeScript 的一些高级特性,如果有什么感想或问题欢迎留言。

谢谢阅读!