如何阻止浏览器超出滚动的弹跳效果

最近做Tauri,发现Tauri虽然少了Chromium大大缩小了安装包的大小,但是用操作系统内本地的浏览器做WebView实在是太不靠谱了,Mac电脑上的Safari简直就是一个大毒瘤,为了搞这个适配问题,感觉回到了兼容IE时代,Tauri的开发者拜托至少给个Chromium的选项别一刀切,比起适配如此痛苦,我真不在乎那几十兆的浏览器。

原文:https://www.bram.us/2016/05/02/prevent-overscroll-bounce-in-ios-mobilesafari-pure-css/

目的:禁止浏览器超出部分滚动的弹跳效果

除Safari以外的浏览器:

html, body {
  overscroll-behavior-y: none;
}

Safari:

html,
body {
  position: fixed;
  overflow: hidden;
}

body > .mainwrapper {
  width: 100vw;
  height: 100vh;
  overflow-y: scroll;
  -webkit-overflow-scrolling: touch; /* enables “momentum” (smooth) scrolling */
}
<html>
…
<body>
  <div class="mainwrapper">
    …
  </div>
</body>
</html>

据该博客的2021年补充说,因为bug上面的方法在ios12里也不支持,我没有测试,但至少在Tauri的浏览器里可以,而且无需加上 mainwrapper等。

flex-shrink 的使用

flex-shrink 根据容器大小和自己原先的尺寸 (flex-basis) ,当空间不足时适当适当缩小,看示例:

<p>The width of content is 460px; the flex-basis of the flex items is 120px.</p>
<p>A, B, C have flex-shrink:1 set. D and E have flex-shrink:2 set</p>
<p>The width of D and E is less than the others.</p>
<div id="content">
  <div class="box" style="background-color:red;">A</div>
  <div class="box" style="background-color:lightblue;">B</div>
  <div class="box" style="background-color:yellow;">C</div>
  <div class="box1" style="background-color:brown;">D</div>
  <div class="box1" style="background-color:lightgreen;">E</div>
</div>
#content {
  display: flex;
  width: 460px;
}

#content div {
  flex-basis: 120px;
}

.box {
  flex-shrink: 1;
}

.box1 {
  flex-shrink: 2;
}

A, B, C, D, E 五个 div 都是 content 容器的子元素,它们的 flex-basis 都是 120px,所以原本它们的宽度和应该是 600px,现在 content 容器宽 460px,超出 140px 由5个子元素分担缩减,ABC 减少1份,DE减少 2 份,总共是 7 份(1+1+1+2+2),所以每份是 20,ABC 减少 20px 最终宽度是 100px,DE 减少 40px 最终宽度80。

演示:

A useful video tutorial about how to setup environment for Vite + React + Typescript + Eslint + Prettier + Vitest and so on

Some bash commands and github link in this video:

npm i -D eslint
npx eslint --init

npx install-peerdeps --dev eslint-config-airbnb
npm install eslint-config-airbnb-typescript
npm i -D prettier eslint-config-prettier eslint-plugin-prettier
# crete cjs file for prettier
npm i -D vitest
npm i -D @testing-library/react @testing-library/jest-dom

https://github.com/CodingGarden/react-ts-starter

用Tauri+React构建桌面应用

Tauri是一个取代Electron的方案,而且比Electron应用方面更广,马上也可以用来创建手机应用,而且架构使用rust作为后端,不嵌入chromium的方式也大大减小app的体积和占用的内存。

简单用一个实例说明如何使用react项目构建一个Tauri App,只需要很短的时间就可以完成此教程。

预先安装

需要先安装好npm、cargo等运行环境,参考 https://tauri.app/zh-cn/v1/guides/getting-started/prerequisites

安装tauri-cli

cargo install tauri-cli

创建react项目

如果已有react项目可以略过这一步

npx create-react-app hello-tauri-react

创建Tauri app

cd hello-tauri-react # 进入react项目的路径中

cargo tauri init

这里会问你6个问题,作为配置写在生成好的tauri.conf.json文件里,结束后会为你创建 src-tauri文件夹

第五个问题问你如何启动前端环境,因为是用CRA创建的项目,因此应该是 npm start

其余问题保持原样就可以

启动测试环境

cargo tauri dev

这里它会先启动react环境,再启动app客户端,当你看到这个界面,就是成功了

客户端界面

你会注意到,浏览器窗口自动弹起来是不必要的,打开tauri.conf.json文件,修改 build -> beforeDevCommand

"beforeDevCommand": "BROWSER=none npm start",

这样下次启动的时候就不会再打开浏览器窗口了

发布打包

cargo tauri build

会报一个错误

`com.tauri.dev` is not allowed as it must be unique across application

就是说app的id标识符不让你用com.tauri.dev这个名字,在tauri.conf.json 中找到 tauri -> bundle -> identifier 随便改成你自己想取的名字,再运行

cargo tauri build

Bingo!现在应该可以发布的app生成好了,我是mac系统,文件生成在 src-tauri/target/release/bundle/ 中 pkg是打包后的,macos里有可运行的app,它还帮你生成了安装包,真的是很愉快的开发体验!

当React 18出现,create-react-app 创建项目遇到的小问题

一不留神,发现react版本已经升级至18,create-react-app里的react版本已经更新为18,但有一些模版还没改过来。

第一,如果安装模块说依赖于react@17.0的话,可以–force强制安装

npm i package-name --force

第二,v18修改了react-dom的使用方法,但因为用ts的模版创建的项目,react和react-dom的types还是老的,报告没有react-dom/client模块,因此在package.json中,删掉dependencies里的@types/react和@types/react-dom,重新安装

npm i @types/react @types/react-dom

这样再使用react-dom/client就可以了

import { createRoot } from 'react-dom/client'

const root = createRoot(document.getElementById('root') as Element)
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)
无聊的谐音梗,“码”被提升

奇怪的变量提升现象

学生提了一个问题

var a = 100
{
  a = 10
  function a() {}
  a = 20
  console.log(a) // 20 
}
console.log(a) // 10

最后打印20和10。这确实是一个奇怪的现象,因为按照以前对函数提升的理解,函数的声明部分会提升到生命周期的最前,赋值会提升到代码块的最前,这个例子显然不符合这个规则,那是怎么回事呢?

stackoverflow上有相同问题,看过答案后,现在我对这段代码的理解是,function的声明在代码块中的提升会产生两个提升,一个是到生命周期的最前端,一个是到代码块的最前端成为一个局部变量(生命周期是块,类似于let),所以形成内外两个生命周期的变量。而且function提升的位置永远比同var更靠前。

这样上面的代码就变成(a1表示外部生命周期的a,a2表示块作用域中的a)

var a1 <-- function 提升到外部的声明
a1 = 100
{
  function a2() {}
  a = 10 <-- 解释器解释到这时候a指向a1,所以a1=10
  a1 = a2 <-- function留在原地的部分要用a1赋值a2
  a = 20 <-- 解释器解释到这的时候a2已经提升了,所以它是a2=20
  console.log(a) // 20 <-- 这里生命周期较近的是a2,所以打印20
}
console.log(a) // 10 <-- 这里是a1,打印10

另外当function在块作用域中提升后,效果类似于let,所以还有这样的效果

{
  var x = 1
  function x() {} // 报错,重复声明
}

还有这样一个效果

{ //block 1
  function foo() { // declared in block 1
    return 1;
  }
  console.log("block 1: foo() === 1", foo() === 1);
  
  { // block 2
    function foo() { // declared in block 2
      return 2;
    }
    console.log("block 2: foo() === 2", foo() === 2);
  }
  
  console.log("block 1: foo() === 1", foo() === 1);
}
console.log("block 1: foo() === 1", foo() === 1);

/* 结果
block 1: foo() === 1 true
block 2: foo() === 2 true
block 1: foo() === 1 true
block 1: foo() === 1 true
*/

运行到block 2(7到12行位置的function foo),发现全局有一个变量已经叫foo,并且它处于block1处的局部变量foo1控制之下,所以会在此代码块里生成一个局部变量foo2(类似let声明的)而不覆盖全局的foo并且区分开和foo1的生命周期,所以造成这种现象。

如果注释掉2到5行,则全局变量foo也会被赋值为 block2处的foo。

总之,推荐用"use strict"可以减少这种奇怪的现象

<script>
"use strict"
var a = 100
{
  a = 10
  function a() {}
  a = 20
  console.log(a) // 20
}
console.log(a) // 100
</script>

useSelector优化

在react函数组件中如果要访问state上的值应该用useSelector,这个函数本身使用很简单,但要注意优化,避免产生性能问题。

基本使用方法

const selectTodos = state => state.todos

const todos = useSelector(selectTodos)

文档上给出的注意要点:

注意:useSelector的结果会用 === 对比,所以如果返回值不同就会导致组件重新渲染,如果不小心在不必要返回新对象的时候返回了新的对象,你的组件会在每次action被触发的时候都重新刷新。

错误做法

// 错误做法
const selectTodoDescriptions = state => {
  // 每次返回一个新的对象
  return state.todos.map(todo => todo.text)
}

因为array.map方法返回的是新的对象,因而虽然每次的内容相同,但是返回的结果用===比较时是不同的,因此每次都会重新渲染。

正确做法

// 正确做法
const todos = useSelector(state => state.todos)

仍然存在问题

这样虽然能解决一点问题,但是对于需要过滤的selector却是无效,因为不论使用什么方法,filter过后的值总是会返回一个新的对象

const filteredTodos = (state, completed) => 
  state.todos.filter(todo => todo.completed === completed)

并且这种情况下使用useMemo也是比较麻烦的。

所以我们要解决的问题是,当选择的条件不变,并且相关数据也没有变化的时候,返回相同的数据。

所幸已经有人解决了这个问题,就是使用reselect库,@reduxjs/toolkit 也已经集成了这个库。

使用createSelector

假设有这样结构的state

{
  users: {ids: ['id1', 'id2'], entities: {id1: user1, id2: user2}}
}

如果使用普通的selector取得用户列表,在render方法中使用useSelector:

const users = useSelector((state) => {
  // run every times when rerender
  console.log("users selector called");
  return state.users.ids.map((id) => state.users.entities[id]);
});

这样虽然能达到效果,但是如果父组件刷新,不但每次都会执行selector,并且还会每次返回一个新的数组,导致列表重新渲染(虽然子组件如果用memo处理仍然是同一个user对象,props不变的情况下不会重新渲染)。

如果用了createSelector,可以解决这个问题。createSelector的调用方式如下:

createSelector(
  depends1, 
  depends2,
  ...,
  selector(depends1result, depends2result, ...) {
    return 结果
  }
)

depends的结构为:

(state, ...args) => dependsResult

args 就是传给selector的额外参数

如果没有任何depends,则selector的形式就是上面的depends函数形式。

之所以需要上面的depends,是因为如果每个依赖项的返回值跟上一次都一样的话,selector就直接返回缓存后的结果,避免重新运算。所以用createSelector版本写出来就是这样:

import { createSelector } from "@reduxjs/toolkit";

export const usersSelector = createSelector(
  (state) => state.users.ids,
  (state) => state.users.entities,
  (_, filter) => filter,
  (ids, users, filter) => {
    // when any of ids, users, filter arguments changes, this selector will run
    console.log("users selector called");
    switch (filter) {
      case "male":
      case "female":
        return ids
          .map((id) => users[id])
          .filter((user) => user.gender === filter);
      default:
        return ids.map((id) => users[id]);
    }
  }
);

这里还加入了filter参数,只要ids/entities/filter参数都没变化,则selector返回的结果不会重新计算,达到了优化效果。

更复杂的完整例子见链接:
https://codesandbox.io/s/useselector-demo-1q18xg?file=/src/App.js

React.forwardRef使用

关于forwardRef的使用做一下总结。

转发ref的目的是为了不会出现this.ref.ref.ref之类的情况,可以对外界暴露内部的ref,因此称为转发(forward)。

应用场景:

  • 获取深层次子孙组件的 DOM 元素
  • 在函数式组件上使用ref赋值(函数组件本身上ref的是无效的,因为它自己没有实例,但forwardRef返回的组件可以)
  • 传递 refs 到高阶组件

需要了解的:

  • ref并非react元素的属性,它经过了特殊处理,因此不能从props取得,类似的属性还有key
  • React.forwardRef只能用于函数组件
  • React.forwardRef返回高阶组件(HOC)
  • React.forwardRef的参数是一个函数组件或者说render函数
  • render函数组件第二个参数是ref,平时这个参数在函数组件中没用,只有作为forwardRef参数时才有效。

注意:forwardRef并不能解决直接选取到第三方组件库中的dom的作用,除非对方提供了forwardRef的支持。

最基本的使用

import React, { useRef, useImperativeHandle } from "react";

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    myfocus: () => {
      inputRef.current.focus();
    },
    myblur: () => {
      inputRef.current.blur();
    }
  }));

  return <input ref={inputRef} />;
}

const FancyInputWrapped = React.forwardRef(FancyInput);

const App = () => {
  const inputRef = useRef(null);

  const handleFocus = () => {
    inputRef.current.myfocus();
  };

  const handleBlur = () => {
    inputRef.current.myblur();
  };

  return (
    <div>
      <FancyInputWrapped ref={inputRef} />
      <button style={{ marginLeft: 8 }} onClick={handleFocus}>
        FOCUS
      </button>
      <button style={{ marginLeft: 8 }} onClick={handleBlur}>
        BLUR
      </button>
    </div>
  );
};

export default () => <App />;

查看:https://codesandbox.io/s/basic-forward-ref-2hjwky

高阶组件转发

注:实际上,我并没有从这个例子中看到特别有用的部分,其实我们没有fowardRef一样可以实现这个过程,如果你不想用forwardRef解决完全是可以的。

import React from "react";

const FocusInput = React.forwardRef((props, ref) => (
  <input type="text" ref={ref} />
));

function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    render() {
      const { forwardedRef, ...restProps } = this.props;
      // 将定义的 prop 属性 forwardRef 定义为 ref
      return <WrappedComponent ref={forwardedRef} {...restProps} />;
    }
  }

  // 注意 React.forwardRef 回调的第二个参数 ref
  // 我们可以将其作为常规 prop 属性传递给 Enhance,例如 forwardedRef
  // 然后它就可以被挂载到被 Enhance 包裹的子组件上
  return React.forwardRef((props, ref) => (
    <Enhance {...props} forwardedRef={ref} />
  ));
}

// EnhancedChildComponet 会渲染一个高阶组件 enhance(FocusInput)
const EnhancedChildComponet = enhance(FocusInput);

// 我们导入的 EnahcnedComponent 组件是高阶组件(HOC)Enhance
// 通过 React.forward 将 ref 将指向了 Enhance 内部的 FocusInput 组件
// 这意味着我们可以直接调用 ref.current.focus() 方法
class App extends React.Component {
  private ref = React.createRef<HTMLInputElement>();

  constructor(props) {
    super(props);
  }
  handleFocus = () => {
    const { current } = this.ref;
    current.focus();
  };
  handleBlur = () => {
    const { current } = this.ref;
    current.blur();
  };
  render() {
    return (
      <>
        <EnhancedChildComponet ref={this.ref} />
        <button style={{ marginLeft: 8 }} onClick={this.handleFocus}>
          FOCUS
        </button>
        <button style={{ marginLeft: 8 }} onClick={this.handleBlur}>
          BLUR
        </button>
      </>
    );
  }
}

export default () => <App />;

查看:https://codesandbox.io/s/hoc-foward-ref-8z727h?file=/App.tsx

useImperativeHandle

此Hook必须配合forwardRef使用,其作用看起来是封装自己ref对象上的方法,其实也不是特别必要使用的技术。

import React, { useRef, useImperativeHandle } from 'react';

function FancyInput(props, ref) {
  const inputRef = useRef();
  useImperativeHandle(ref, () => ({
    myfocus: () => {
      inputRef.current.focus();
    },
    myblur: () => {
      inputRef.current.blur();
    }
  }));

  return <input ref={inputRef} />;
}

const FancyInputWrapped = React.forwardRef(FancyInput);

const App = () => {

  const inputRef = useRef(null)

  const handleFocus = () => {
    inputRef.current.myfocus();
  }

  const handleBlur = () => {
    inputRef.current.myblur();
  }

  return (
    <div>
      <FancyInputWrapped ref={inputRef} />
      <button style={{ marginLeft: 8 }} onClick={handleFocus}>FOCUS</button>
      <button style={{ marginLeft: 8 }} onClick={handleBlur}>BLUR</button>
    </div>
  )
};

export default () => <App />

查看:https://codesandbox.io/s/ji-ben-yong-fa-forked-2hjwky?file=/App.tsx

参考

React.StrictMode下会渲染两次

不废话直接代码

App.js

import { useState } from "react";
import "./styles.css";

export default function App() {
  const [count, setCount] = useState(0);
  const promise = Promise.resolve("hello");
  console.log(promise);
  promise.then((c) => console.log(c));
  return (
    <div className="App">
      <h1 onClick={() => setCount(count + 1)}>count: {count}</h1>
    </div>
  );
}

index.js

import { StrictMode } from "react";
import ReactDOM from "react-dom";

import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <StrictMode>
    <App />
  </StrictMode>,
  rootElement
);

看到每次渲染会打印两次hello,如果去掉index.js里的StrictMode则只会打印一次hello。

官方作者的说法是在StrictMode下的development环境里,会render两次,但是打印promise却只有一次!

更奇怪的是,如果仍然在StrictMode下,将第8行注释掉,第9行注释取消,则只会打印hello一次。这个我暂时无法理解😣

代码:https://codesandbox.io/s/strict-mode-render-twice-gxdick?file=/src/App.js

useMemo和useCallback

本文探究useMemo和useCallback两个Hook究竟应该如何使用。

比较useMemo和useCallback

首先,useMemo和useCallback在函数的参数方面类似

useMemo(callback, deps)
useCallback(callback, deps)

两者的形式的确相似,第一个参数都是回调函数,第二个参数是依赖关系(useEffect的依赖关系),先解释依赖关系

  • 当deps是null/undefined时候无论是挂载还是更新阶段都会返回新的值,所以在useMemo或useCallback中deps是null值是没有意义的!(但是对于useEffect是有意义的);
  • 当deps是空数组[],则只在挂载阶段返回一次值;
  • 当deps是变量列表时,如[a, b]则a或b中任何一个值发生改变则要返回新值。

这是useMemo和useCallback的相似之处,但实际上两个函数在参数的签名上不完全一样,第一个回调函数参数,useCallback是允许有参数的,但useMemo是没有参数的!

const memo = useMemo((a) => a+b, [b]) // 参数a没有机会传
const memo = useMemo(() => a+b, [a, b]) // 这样才可能是正常的

const handleClick = useCallback((e) => { // 这是很常见的
  e.preventDefault()
  ...
}

useMemo和useCallback 的返回值不同

  • useMemo返回的是回调函数运行后的返回值
  • useCallback返回的是回调函数

可以把下面的代码视为同等

const handleClick = useCallback((page) => dispatch(fetchFeeds(page)), [dispatch])
const handleClick = useMemo(() => (page) => dispatch(fetchFeeds(page)), [dispatch])

这里用useMemo返回了一个function,和上一行useCallback做的事情一样,只不过通常我们并不会这样写。

useMemo要点

useMemo的主要作用在于如果组件产生一次新的渲染,只要缓存值的依赖关系没有变,就仍然返回上次缓存的值,节省了运算时间,并且保持计算值不变(有可能避免额外的子组件渲染)。

官网说明也需要注意

你可以把 useMemo 作为性能优化的手段,但不要把它当成语义上的保证。将来,React 可能会选择“遗忘”以前的一些 memoized 值,并在下次渲染时重新计算它们,比如为离屏组件释放内存。先编写在没有 useMemo 的情况下也可以执行的代码 —— 之后再在你的代码中添加 useMemo,以达到优化性能的目的。

其实就是说缓存值并不一定是缓存的,缓存有可能丢失!

另外要注意无论是useMemo还是useCallback它们并不会把历史缓存值返回,返回的都是上一次缓存住的值!

useCallback的使用

有人在凡是用到回调函数的地方不论什么情况都用useCallback处理一下,认为这样能提高性能,是不对的,不清楚useCallback的工作原理反而可能会造成效率下降运行变慢。我们必须厘清在什么情况下才应该使用useCallback。

首先,之所以要使用useCallback是为了避免不必要的属性变化而造成子组件重新渲染,从这个角度来说,直接绑定在dom上的回调事件没有必要用useCallback处理

function MyComponent() {
  const handleClick = useCallback(() => {...}, [...]) // 这样没什么用
  return (<button onClick={handleClick}></button>)
}

要正确使用useCallback,需要理解function是一种引用类型,在闭包内创建的function每次都会是一个新的对象,所以如果这样将这个函数作为参数传给子组件的时候,虽然函数做的事情没有变,但是作为函数的对象已经发生了改变,则会造成子组件重新渲染,所以为了避免这种情况,应该使用useCallback。

假设,我们渲染一个很长的数组列表,所有列表元素上都有一个父元素传来的onClick回调,这时如果父组件刷新,某个不应该影响子组件渲染的属性发生改变,如果用了useCallback将减少很多不必要的子组件渲染。

示例:https://codesandbox.io/s/usecallback-demo-gozqsm?file=/src/App.js

此示例中有一个大列表,如果传给它的onItemClick回调用useCallback处理和不用它处理的效果不同(需要注意的是,MyList是一个memo后的组件,否则父组件只要重新render,即使子组件的属性完全没变也仍然会渲染)。现在当父组件重新渲染时handleClickCached函数每次都是一样的值,因此list2不会重新渲染,而list1重新渲染其下的每个子组件也会随之重新渲染造成资源浪费

因此要注意区分开什么情况下才应该使用useCallback,useCallback的返回值并非没有开销,如果是没有大量的子组件渲染,则不必一定useCallback处理

最后,凡是开发过程中其实都可以先不用useMemo或useCallback处理,只在事后优化的时候再酌情添加,而且这些值的使用往往也要和React.memo配合