普通视图

发现新文章,点击刷新页面。
昨天以前CRIMX Blog

Web Extension Live Reloading

2020年7月9日 02:58

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年7月1日 00:20

自宣布一年多过去 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 没有断裂问题。

RxJS Hooks and Suspense: The Ultimate Guide

2020年2月26日 01:58

(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年1月11日 14:13

问题

首先,我们知道 TypeScript 很早(2017)前就实现了带默认值的泛型(generic)

type Foo<T = number> = T

// $ExpectType number
type T1 = Foo

// $ExpectType string
type T2 = Foo<string>

如果我们定义这么一个部分泛型带默认值的类型

type Pluck<TObj extends {}, TKey extends keyof TObj = keyof TObj> = TObj[TKey]

那么这个默认值是可以被正确推导的

type Obj = { a: number, b: string }

// $ExpectType string | number
type Props = Pluck<Obj>

// $ExpectType string
type Prop = Pluck<Obj, 'b'>

对于函数来说,TypeScript 允许泛型从参数中推导

function identity<T>(arg: T): T {
  return arg
}

const text = identity('text')

// $ExpectType 'text'
typeof text

有时如果泛型不在参数中,那么使用时我们就要提供

function fetchJSON<TResult>(src: string): Promise<TResult> {
  return fetch(src).then(r => r.json())
}

// $ExpectType Promise<{ result: string }>
fetchJSON<{ result: string }>('http://blog.crimx.com/json')

现在问题就来了,如果我们希望只提供部分的泛型,而剩下的泛型可以推导

function fetchData<TResult, TKey extends keyof TResult>(
  src: string,
  key: TKey
): Promise<TResult[TKey]> {
  return fetch(src)
    .then(r => r.json())
    .then(json => json[key])
}

// Argument of type '"result"' is not assignable to parameter of type 'never'.ts(2345)
// $ExpectError
fetchData('http://blog.crimx.com/json', 'result')

// Expected 2 type arguments, but got 1.ts(2558)
// $ExpectError
fetchData<{ result: string }>('http://blog.crimx.com/json', 'result')

可以看到,如果没有给泛型提供值,那么 TKey 会以 TResult 定义时的值 unknown 进行推导;如果只提供 TResult 那么 TypeScript 会要求提供全部值。

这个特性目前依然存在争议,要求泛型从两个地方进行推导可能会引起混淆。

解决

看回我们的问题,其实我们之所以要提供 TResult 是为了声明 fetch 的结果。我们是做了这两件事

fetchJSON<{ result: string }>('http://blog.crimx.com/json')
  .then(json => json[key])

可以用柯里化的方式解决

function fetchData<TResult>(
  src: string
): <TKey extends keyof TResult>(key: TKey) => Promise<TResult[TKey]> {
  return key =>
    fetch(src)
      .then(r => r.json())
      .then(json => json[key])
}

// $ExpectType Promise<string>
fetchData<{ result: string }>('http://blog.crimx.com/json')('result')

其它的应用也可以归类到这个模式,这严格上说不算是“解决”而是“变通”。牺牲运行时来让 ts 编译器满意实在有点膈应,希望未来的读者有缘读到这篇时已经出现了更好的解决方案。

谢谢阅读!

实现一个有趣的 RxJS Operator

2019年10月21日 13:28

问题

最近有这么一个情况,生产者会产生 { id, value } 结构的值,下游接收发起异步操作。如果是同个 id 那么后产生的值总会覆盖前者,前者发起的异步如果返回得比较晚则需要丢弃过时的值。

所以这里就有点类似于 switchMap 但不同的是,switchMap 总会抛弃前者,而这里只有 id 相同才会抛弃。

往下阅读之前不妨想想可以如何解决。

排除

首先这里肯定不能是基于 switchMap,因为我们需要保留不同 id 发起的异步结果。

那么剩下的子流归并操作是 mergeMapconcatMapconcatMap 一般用于子流产生多个顺序值,所以这里也不适用。

mergeMap 是最普通的归并,没有其它合适 Operator 情况下我们就根据它来实现一个自定义的 Operator。

思路

从另一个角度看这个问题,我们只需要根据 id 产生一条子流,之后如果出现同个 id 的项则取消这条子流。

对于判断后来的同个 id 值,我们可以借用一条只有这个 id 值的流。

takeUntil(input$.pipe(filter(input => input.id === id)))

所以这个思路就很明显了。

import { Observable, OperatorFunction } from 'rxjs'
import { mergeMap, takeUntil, filter } from 'rxjs/operators'

export function switchMapBy<T, R>(
  key: keyof T,
  project: (val: T) => Observable<R>
): OperatorFunction<T, R> {
  return input$ => {
    return input$.pipe(
      mergeMap(val =>
        project(val).pipe(
          takeUntil(input$.pipe(filter(input => input[key] === val[key])))
        )
      )
    )
  }
}

优化

在复用了流的情况下,如果这个 Operator 使用时排在较后的位置,那么它前面的操作就要都执行两次,我们可以用将流转热避免这个问题。

import { Observable, OperatorFunction } from 'rxjs'
import { mergeMap, takeUntil, filter, share } from 'rxjs/operators'

export function switchMapBy<T, R>(
  key: keyof T,
  project: (val: T) => Observable<R>
): OperatorFunction<T, R> {
  return input$ => {
    const input$$ = input$.pipe(share())    return input$$.pipe(
      mergeMap(val =>
        project(val).pipe(
          takeUntil(input$$.pipe(filter(input => input[key] === val[key])))
        )
      )
    )
  }
}

最后我们还可以让 project 支持返回 Promise

import { Observable, OperatorFunction, from } from 'rxjs'
import { mergeMap, takeUntil, filter, share } from 'rxjs/operators'

export function switchMapBy<T, R>(
  key: keyof T,
  project: (val: T) => Observable<R> | Promise<R>
): OperatorFunction<T, R> {
  return input$ => {
    const input$$ = input$.pipe(share())
    return input$$.pipe(
      mergeMap(val =>
        from(project(val)).pipe(          takeUntil(input$$.pipe(filter(input => input[key] === val[key])))
        )
      )
    )
  }
}

最后

实现自定义 Operator 的确是一个比较好的练手机会,对于重新审视理解流有一定帮助。这种流复用的思考方式还得多加训练才能一步到位。

❌
❌