GithubHelp home page GithubHelp logo

blog's People

Contributors

dependabot[bot] avatar susucain avatar

Stargazers

 avatar  avatar

Watchers

 avatar

blog's Issues

React服务端渲染二三事

为何使用

传统SSR

SSR服务端渲染(英语:server side render)。服务端渲染把数据的初始请求放在了服务端,服务端收到请求后,把数据填充到模板形成完整的页面,由服务端把渲染的完整的页面返回给客户端。

SSR 优缺点

服务端直出HTML会让首屏较快展现,且利于SEO。但所有页面的加载都需向服务端请求,如果访问量较大,会对服务器造成压力。此外,页面之间的跳转,页面局部内容的变动都会引起页面刷新,体验不够友好。

CSR

CSR客户端渲染(英文:Client Side Rendering)。
它是目前 Web 应用中主流的渲染模式,一般由 Server 端返回初始 HTML 内容,然后再由JS 去异步加载数据,再完成页面的渲染。客户端渲染模式中最流行的开发模式当属SPA(单页应用)。这种模式下服务端只会返回一个页面的框架和js 脚本资源,而不会返回具体的数据。

CSR(SPA) 优缺点

只有首次进入或刷新时需要请求服务器,页面之间的跳转由JS脚本完成,响应较快。但由于服务端只返回一个空节点的HTML,页面内容的呈现需等待JS脚本加载执行完毕,首屏时间较长,对SEO也不友好。

React SSR

相比于客户端渲染SPA应用

由于首次进入或刷新页面时,服务端直接将有内容的页面返回给客户端,大大降低了白屏时间,同样也便于做SEO

相比于传统的SSR应用

不必跳转到不同页面都需要刷新一次浏览器,只在第一次访问的时候服务端直出HTML,后续的页面跳转走CSR(客户端渲染)

如何使用

组件同构

服务端

对于一个React应用,想在首次进入或刷新页面服务端就直接返回完整的HTML,需要在服务端将当前页面需渲染的组件转换成HTML,React为此提供了相应方法。

import { renderToString } from 'react-dom/server'

const html = renderToString(
  <App />
)

renderToString方法外,Reeact也提供了ReactDom.renderToNodeStream方法,返回一个可输出HTML 字符串的可读流。

客户端

虽然服务端已经直出了需要渲染的HTML,但一些事件绑定的操作还是需要客户端JS脚本来完成。如果客户端依旧执行ReactDOM.render方法,会在首次调用时将容器节点下的所有DOM元素替换,这显然不是我们想要的结果,好在React提供了ReactDom.hydrate方法。

import ReactDom from 'react-dom'

ReactDom.hydrate(
  <App />,
  document.getElementById('root')
)

与 render() 相同,但它用于在 ReactDOMServer 渲染的容器中对 HTML 的内容进行 hydrate 操作。React 会尝试在已有标记上绑定事件监听器。

在客户端渲染时,hydrate方法会比较双端渲染结果是否一致,如一致则保留服务端渲染的结果,如不一致则使用客户端渲染的结果。

路由同构

SPA应用的路由完全由客户端控制,跳转到不同路径时JS脚本会替换组件使之呈现出不同内容。

而对React SSR应用来说,只要用户首次进入或刷新页面时服务端能渲染正确的组件即可,后续的路由切换则完全由客户端JS脚本控制。react-router提供StaticRouter组件可根据当前请求路径匹配渲染不同组件。

import { StaticRouter } from 'react-router'

// path为请求路径
<StaticRouter location={path}>
  <App />
</StaticRouter>

数据同构

有时需要在服务端请求数据,直接渲染出带数据的HTML页面。而由于客户端无此数据,渲染内容就会和服务端不一致。那如何将服务端请求的数据“注入”客户端呢?

数据注水

服务端可以控制直出的HTML内容,既然如此就可以在直出的HTML内容中插入一段脚本。next.js便是如此实现的

image

数据脱水

服务端已将数据写入script标签中,客户端渲染时便可直接用该数据进行渲染。

SEO TDK支持

如果只是要做SEO支持,可以全部放在服务端,根据不同页面路径直出不同的TDK数据。

但为保证体验和提高可维护性,最好是能将TDK写在页面组件里。这需要服务端渲染时能获取到当前页面的TDK数据,直出到HTML,客户端渲染时能够比较TDK数据,做DOM操作用新页面的TDK数据替换掉老页面的。

react-helmet对此提供了较好的支持

服务端示例(Koa)

import React from 'react'
import { renderToString } from 'react-dom/server'
import { Helmet } from 'react-helmet'


export default async (ctx, next) => {
  const html = renderToString(
    <App />
  )

  const helmet = Helmet.renderStatic()

  ctx.body = `<!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
          ${helmet.title.toString()}
          ${helmet.meta.toString()}
      </head>
      <body>
        <div id="root">${html}</div>
      </body>
    </html>
  `
 
  return next();
}

客户端示例

import { Helmet } from 'react-helmet'
import tempData from './data'

const Index = () => {
  return (
    <>
      <Helmet>
        <title>index title</title>
        <meta name="description" content="index description"></meta>
        <meta name="keyword" content="index keyword"></meta>
      </Helmet>
      <div>Index</div>
    </>
  )
}

export default Index

CSS同构

目前next.jsegg-react-ssr都是将css代码最终打包到一个文件内作为资源进行加载。

按这种方式,服务端直出的HTML结果应包含css文件的link标签,而客户端JS脚本无需插入link标签。

客户端

客户端使用mini-css-extract-plugin插件提取css文件,如果使用了按需加载还需将所有css提取为单个文件

const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module.exports = {
  optimization: {
    splitChunks: {
      cacheGroups: {
        styles: {
          name: 'styles',
          type: 'css/mini-extract',
          // For webpack@4
          // test: /\.css$/,
          chunks: 'all',
          enforce: true,
        },
      },
    },
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
  ],
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
    ],
  },
};

在打包时一般静态资源文件名会带上hash值,这时为保证服务端能获取到正确的路径还需使用webpack-manifest-plugin插件生成文件名和路径的映射文件,方便服务端获取正确的路径。

如果使用react-loadable做按需加载,则可使用其提供的react-loadable-ssr-addon插件生成映射文件。

服务端

如果没有启用css模块化,客户端打包时已将用到的css打包到了一个文件,服务端就无需处理css文件。

webpack中可使用ignore-loader忽略css文件的处理

module.exports = {
  // other configurations
  module: {
    loaders: [
      { test: /\.css$/, loader: 'ignore-loader' }
    ]
  }
};

如果启用了css模块化,css-loader会生成标识符映射,服务端也需要生成标识符以保证双端渲染结果一致。css-loadermodule.exportOnlyLocals选项提供了仅导出标识符映射,而不签入css的功能

module.exports = {
  module: {
    rules: [
      {
        test: /\.css$/i,
        loader: "css-loader",
        options: {
          modules: {
            exportOnlyLocals: true,
          },
        },
      },
    ],
  },
};

性能优化

按需加载(代码分割)

对一个较大项目来说,我们访问其中的一个页面时,只需加载当前页面的代码,其他页面的代码只要在访问的时候再加载即可。这可以有效减少访问一个页面时需加载的js文件体积,提高页面响应速度。

在一个SPA应用中实现按需加载,只需使用dynamic import语法,webpack支持该特性。使用动态import语法导入的模块会被单独打包到一个文件。

React SSR实现按需加载的一些坑

  1. 服务端渲染组件时,无法将按需加载的组件渲染成HTML
  2. 即使服务端能够直出按需加载组件的HTML,客户端接管后由于异步JS代码尚未加载,会先展示中间状态(一般中间状态会先渲染loading),这样双端的初次渲染结果就不一致了
  3. 既然采取了服务端渲染,在服务端渲染时直出的HTML最后能包含当前页面需异步加载的JS代码,即script标签,无需客户端动态创建

针对第一点,其实服务端无需按需加载,应直接渲染出按需加载的组件;

第二点,为保证双端初次渲染结果一致,客户端应该等待当前页面按需加载的异步JS代码下载后再进行渲染;

第三点,为使服务端渲染的HTML能够包含按需加载的JS代码的script,需要获取到当前页面按需加载的组件名,和组件名对应JS路径名的映射。在直出HTML时将当前页面按需加载的script标签拼接进去。

react-loadable提供了上述问题的解决方案

react-loadable实现

组件

按需加载的组件使用Loadable包裹,其中moduleswebpack选项标识组件加载的是哪个模块,这样服务端就能根据渲染的组件获取到需加载的模块。

如果使用babel插件react-loadable/babel,便无需使用moduleswebpack选项。

import Loadable from 'react-loadable';

Loadable({
  loader: () => import('./Bar'),
  modules: ['./Bar'],
  webpack: () => [require.resolveWeak('./Bar')],
});

客户端

使用preloadReady方法,等待按需加载的script脚本加载完毕后再渲染。window.main方法将在服务端直出的script脚本加载后调用。

import Loadable from 'react-loadable'

window.main = () => {
  Loadable.preloadReady().then(() => {
    ReactDom.render(
      <App />
      document.getElementById('root')
    )
  })
}

生成模块映射(webpack)

生成加载的模块与webpack打包后的bundles的映射,服务端可据此判断应直出的scirpt

const ReactLoadableSSRAddon = require('react-loadable-ssr-addon');

module.exports = {
  entry: {
    // ...
  },
  output: {
    // ...
  },
  module: {
    // ...
  },
  plugins: [
    new ReactLoadableSSRAddon({
      filename: 'react-loadable.json',
    }),
  ],
};

服务端(Koa)

将渲染的组件用Loadable.Capture包裹,它提供一个回调report方法,可以记录当前页面按需加载的模块名,根据生成的模块映射可获取到webpack打包后的bundles,由此直出当前页面需按需加载的scirpt,无需客户端动态创建。

mport React from 'react';
import { renderToString } from 'react-dom/server';
import Loadable from 'react-loadable'
import { getBundles } from 'react-loadable-ssr-addon';
import manifest from '@dist/server/react-loadable.json';

export default async (ctx, next) => {

  const modules = new Set();

  const html = renderToString(
    <Loadable.Capture report={moduleName => {
      modules.add(moduleName)
    }}>
      <App />
    </Loadable.Capture>
  );

  const modulesToBeLoaded = [...manifest.entrypoints, ...Array.from(modules)];
  const bundles = getBundles(manifest, modulesToBeLoaded);

  ctx.body = `<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        ${bundle.css.join('\n')}
    </head>
    <body>
        <div id="root">${html}</div>
    </body>
    </html>
    ${assets.js.join('\n')}
    <script type="text/javascript">
        window.main()
    </script>
`;

  return next();
}

参考文档

单页应用前端(React)项目部署

单页应用前端(React)项目部署

前端路由的原理是根据当前页面路由渲染不同的组件,这一过程由JavaScript完成,无需经过后台。传统的多页应用每个路由对应一个HTML文件,跳转到某个路径时,浏览器要向服务端请求对应的静态文件资源。采用前端路由则省去了这一过程,提升了性能。

history路由

前端如果使用history路由,在刷新页面或直接输入地址访问时浏览器会向服务端请求对应路径下的资源,如果这时服务端没有路由匹配则会返回404。

所以,服务端对单页应用的主要处理是对返回404的情况统一返回单页应用的index.html文件。

注意:配置后需前端针对匹配不到路由的情况写一个404页面,否则会直接白屏

create-react-app前端项目配置

前端使用BrowserRouter路由,如果前端包不部署在二级域名下只需将package.json文件的homepage字段指定为/就可以了(create-react-app脚手架使用package.json文件的homepage字段作为WebpackpublicPath配置项)

如果前端包需在二级域名下访问,则需指定homepage字段为二级路由名,如/sub。还需指定前端路由的basename属性

<BrowserRouter basename="/sub">

后台

Nginx

使用Nginx做服务器,需要将前端包部署在nginx默认的静态资源路径下,一般是/usr/share/nginx/html下。

如项目部署在路径/sub下,则配置如下:

location /sub/ {
	error_log /var/log/nginx/sub.log debug;
    try_files $uri $uri/ /sub/index.html;
    proxy_set_header            Host $host:$server_port;
    proxy_set_header            X-real-ip $remote_addr;
    proxy_set_header   	       X-Forwarded-For  $remote_addr;
}

其中error_log指令用于输出调试日志,调试完成后不需要这条指令。意味将日志文件输出到/var/log/nginx/sub.log路径中。

try-files指令意为寻找文件$uri,如果找到返回该文件,寻找目录$uri,如果找到返回该目录下的同名文件,否则返回/sub/index.html文件

image

如图中,访问地址是/scooper-ifpc-web/lib/prompt/prompt.css,nginx会默认在自己的静态资源文件中查找
/usr/share/nginx/html/scooper-ifpc-web/lib/prompt/prompt.css如找到,就返回这个文件。

Tomcat

Tomcat只需在配置文件web.xml文件中写入如下配置即可

<error-page>
    <error-code>404</error-code>
    <location>/index.html</location>
</error-page>

当请求在服务端没有路由匹配时返回单页应用的index.html文件

Express

使用Node.jsExpress框架只需引入connect-history-api-fallback中间件

中间件源码很简单,截取一部分

    rewriteTarget = options.index || '/index.html';
    logger('Rewriting', req.method, req.url, 'to', rewriteTarget);
    req.url = rewriteTarget;
    next();

核心代码就是一个简单的重定向,将请求url改为单页应用index.html文件所在路径。重定向需要满足请求方法是GET且请求头的accept子段是['text/html', '*/*']之一。

zTree插件的一个性能问题

背景

之前做过的项目在通讯录成员有1000个时,网页加载要30s才能加载完全。

想到可能的原因有两种

  1. 接口响应时间过长
  2. js脚本执行时间过长,由于浏览器的渲染是单线程的,js脚本的执行阻塞了UI线程的渲染

定位问题原因

打开Network面板,刷新页面重新加载后发现接口的响应时间只有1.43秒,所以排除是接口响应时间过长导致的问题。

image

排除掉接口响应时间长的问题后,用Performance面板分析是否是脚本执行时间过长导致的问题。

点击Record按钮后重新加载通讯录,待通讯录加载完毕后结束录制,查看。

其中黄色部分是脚本执行的时间,发现脚本执行时间占了大部分。

image

至此,确定是脚本执行时间过长阻塞了浏览器渲染。

查看在此期间的函数调用栈,发现createNodeCallback函数执行时间长达29.75秒,而这个函数调用了数次apply方法和addDiyDom方法。

找到函数的定义位置,发现函数执行了1000次的循环,循环调用addDiyDom方法。如果setting.callback.onNodeCreated存在还会触发1000次NODECREATED事件。而这两项都是挂在setting,也就是用户的配置上。

image

由于项目的配置中没有onNodeCreated项,所以在代码中注释掉addDiyDom方法,再次用Performance面板分析

image

可以看到脚本的执行时长大大减少。

查看addDiyDom方法

image

该方法查找了页面上的DOM元素,并直接对页面上的DOM元素进行了修改;而在JS中对DOM的操作最是损耗性能。

至此,问题原因定位完毕,由于zTree插件连续调用1000次对页面上的dom节点直接进行修改的addDiyDom方法,造成了脚本执行时间过长,页面卡顿的性能问题。

寻找解决方式

查看源码发现createNodeCallback方法是在appendParentULDom方法之后调用,而appendParentULDom方法所做的正是把zTree插件生成的dom树插入到页面中。所以,用户定义的addDiyDom方法就只能对页面上的DOM直接进行操作了。

image

由于业务需求,需要在人员节点上增加两个按钮。可不可以在生成节点的时候就吧自定义按钮插入进去呢?这样就可以避免直接操作页面上DOM的性能问题

在官网的API中也没有找到可以在生成节点的时候把用户自定义的节点插入的方法。只好再翻一下源码。

找到生成dom节点的位置

image

在这些方法中一个个找可以插入节点的地方,zTree的节点结构是固定的,外层是一个li元素,第一个子节点span元素用作(展开/收起)的按钮,接下来是一个a标签。a标签中第一个元素是节点名称前的图标,第二个是展示用户配置数据的节点名称data[i].name

image

源码中第一个方法生成li元素的开始标签部分;第二个方法生成span.switch元素;第三个方法中查询用户配置,如果用户配置了setting.check.enable为true,则会在a元素前插入一个选择框;第四个方法生成a标签的开始部分;第五个方法从名称上看是向a元素前插入节点,前一个方法刚好生成了a标签的开始部分,如果这个方法可以插入节点,问题就能解决。似乎看到了一丝希望。

断点打到getInnerBeforeA内部

image

啊嘞嘞,这个函数要想起作用,必须_init.innerBeforeA数组中有方法才行,那有什么办法可以向_init.innerBeforeA数组push函数?

在文件中查找innerBeforeA

image

看来要想往_init.innerBeforeA数组push函数得调用data.addInnerBeforeA才行,那data这个对象有没有暴露给用户呢?

通过VS Code查找这个对象的引用

image

果然是有暴露给用户的~

这样就可以在zTree初始化前自定义一个方法,将我需要定制的节点插入a元素中。

在该函数中打断点查看调用栈,找到传入的三个参数settingnodehtmlsetting是用户的配置。用户传入的对应数据上的属性会挂在node对象上。html是前面几个方法生成的html数组,正好到a标签的开始部分(最后zTree会调用.join方法转成字符串,再调用jquery的append方法,插入到页面中)。这时只要向a标签中push进我需要定制的节点就可以了。

$.fn.zTree._z.data.addInnerBeforeA(function (setting, node, html) {
    if (setting.view.enableDiyDom && node.dataType === 'orgMember') {
        html.push('<button class="mem-btn-video" btntype="yy" title="云眼"></button>');
    }
});

由于该方法可以拿到用户配置,所以可以自定义配置。在这里自定义配置项:setting.view.enableDiyDom。只有当它为true,且节点类型是成员(orgMember)才会添加自定义按钮。如果页面中多个地方用到了zTree插件,那么必须用自定义配置项去做限制,否则自定义的addInnerBeforeA方法会污染所有用到zTree插件的地方。

添加之后查看元素面板,自定义的元素在其中,且是a元素的第一个子元素。

image

打开Performance面板进行分析

image

可以看到脚本的执行的时长仍在1秒左右,至此,问题解决

总结

  • zTree插件提供的addDiyDom方法在大数据量的加载时会导致显著的性能问题,因为该方法添加自定义节点时只能直接对页面上的DOM直接进行操作。

  • 如果用户配置了onNodeCreated方法,且在该方法中进行了DOM操作,在大数据量的加载中也会导致显著的性能问题,原因与addDiyDom方法相同。

  • 添加自定义节点可以使用zTree的内部方法

    • addInnerBeforeA插入的节点作为a元素的第一个子元素
    • addInnerAfterA插入的节点作为a元素的最后一个子元素
    • addAfterA插入的节点作为a元素后的兄弟元素
  • 如果是在鼠标悬浮时才显示用户的自定义元素,可使用setting.view.addHoverDomsetting.view.removeHoverDom配置项

  • 加载1000个节点,js脚本的执行时间已经达到了1秒,如果要求加载的数据更多,或有进一步的优化要求就只能考虑分页加载了

盘点工作中遇到的bug(持续更新)

服务端渲染有样式,但客户端渲染却没有?

首先看这样一个demo

import React from 'react';

function App() {
  return (
    <p style={{ background:'cyan; xxx' }}>
        App
    </p>
  );
}

export default App;

这个demo在服务端渲染时,刷新页面会发现p元素的背景色设置成功了。

image

但当此demo在客户端渲染时(多数SSR框架比如Next.js,在路由跳转时都采用客户端渲染)背景色却没设置成功。

image

到底是什么的不同造成了这样的差异呢?

在服务端渲染时,node端将调用renderToString()renderToNodeStream方法生成HTML字符串。在浏览器访问时服务端直出HTML字符串,通过React.hydrate比较双端渲染结果是否一致,一致则直接使用服务端渲染的结果,并在已有标记上绑定事件监听器。

可以看到相比于客户端渲染,整个服务端渲染的流程中最大的不同是少了真实DOM的渲染。

接下来分析虚拟DOM映射到真实DOM的过程,也就是completeWork这个方法的调用栈,重点查看style属性设置的方法

image

这里其实从方法名就能看出设置style属性的是哪个方法

image

可以看到React中设置style属性的方式,就是设置Element.style属性。此属性会返回一个CSSStyleDeclaration对象,如果设置的css属性值有语法错误就会失败。

Raised if the specified CSS string value has a syntax error and is unparsable.

有趣的是我试了试通过Element.setAttribute()设置属性值,是可以成功的。

ele.setAttribute('style', 'background:cyan; xxx')

参考链接

CSS-CSSStyleDeclaration

ahooks源码分析之usePersistFn

usePersistFn

usePersistFn可以持久化function,保证函数地址永远不会变化。

import { useRef } from 'react';

export type noop = (...args: any[]) => any;

function usePersistFn<T extends noop>(fn: T) {
  const fnRef = useRef<T>(fn);
  // 每次渲染fn的最新值都会记录在fnRef中
  fnRef.current = fn;

  const persistFn = useRef<T>();
  // 初次渲染时给persistFn赋值,此后persistFn不会更新
  if (!persistFn.current) {
    persistFn.current = function (...args) {
      return fnRef.current!.apply(this, args);
    } as T;
  }

  // 返回persistFn,感叹号表示返回值类型非null或undefined,因为初次渲染时persistFn就被赋值为了函数
  return persistFn.current!;
}

export default usePersistFn;

为什么要用usePersistFn?

React官方文档中提到

在某些场景中,你可能会需要用 useCallback 记住一个回调,但由于内部函数必须经常重新创建,记忆效果不是很好,导致子组件重复 render。对于超级复杂的子组件,重新渲染会对性能造成影响。通过 usePersistFn,可以保证函数地址永远不会变化。

官方给出的demo如下

function Form() {
  const [text, updateText] = useState('');
  const textRef = useRef();

  useEffect(() => {
    textRef.current = text; // 把它写入 ref
  });

  const handleSubmit = useCallback(() => {
    const currentText = textRef.current; // 从 ref 读取它
    alert(currentText);
  }, [textRef]); // 不要像 [text] 那样重新创建 handleSubmit

  return (
    <>
      <input value={text} onChange={e => updateText(e.target.value)} />
      <ExpensiveTree onSubmit={handleSubmit} />
    </>
  );
}

ExpensiveTree是一个复杂的子组件,其接受一个props handleSubmit函数。如果使用useCallback,由于handleSubmit函数内部使用了text变量,便要写为如下形式:

const handleSubmit = useCallback(() => {
    alert(text);
  }, [text]); 

只要text发生变化,useCallback接收的内部函数便要重新创建,导致handleSubmit函数的引用地址发生变化。进而引起子组件ExpensiveTree的重渲染,对性能产生影响。

usePersistFn的目标便是持久化接收的函数,且调用时内部函数引用的变量(上例为text)能获取到实时的值(useCallback的依赖传空数组也能实现持久化函数,但无法获取实时的值)

官方给的demo中更新textRef写在了useEffect中,为什么usePersistFn不这样实现?

参见这个issue

如果在子组件的useEffect回调函数中调用usePersistFn就会出现问题。因为渲染时会先执行子组件的useEffect,后执行父组件自定义hooks的useEffect

demo参见在useEffect中更新fnRef

Typescript 4.1新特性一览

模板字符串类型(Template Literal Types)

它的语法与ES6的模板字符串相似,只是用于类型。此外,用在模板字符串类型中的泛型或类型别名,类型必须满足是string | number | bigint | boolean | null | undefined之一。

type TestError<T> = `${T}Changed`; // error: Type 'T' is not assignable to type 'string | number | bigint | boolean | null | undefined'.
type EventName<T extends string> = `${T}Changed`;
type T0 = EventName<'foo'>;  // 'fooChanged'
type T1 = EventName<'foo' | 'bar' | 'baz'>;  // 'fooChanged' | 'barChanged' | 'bazChanged'

字符串模板中的联合类型会进行排列组合(生成每个联合类型成员表示的所有可能的字符串字面量的集合)

type Color = "red" | "blue";
type Quantity = "one" | "two";


type SeussFish = `${Quantity | Color} fish`;
// "one fish" | "two fish" | "red fish" | "blue fish"

为了更方便对字符串字面量进行操作,Typescript4.1新增了几个预定义的类型别名,分别是UppercaseLowercaseCapitalizeUncapitalize。 分别是大写、小写,首字母大写,首字母小写。

type Test<T extends string> = `${Uppercase<T>} ${Lowercase<T>} ${Capitalize<T>} ${Uncapitalize<T>}`;

type T = Test<'test'>
// TEST test Test test

类比Typescript 4.0推出的可变元祖类型,其实模板字符串也可以叫做“可变字符串类型”。此特性的推出大大增强了对字符串字面量的操作。

待补充,此特性还可被用于推断中,再参考一下微信公众号那篇文章

映射类型中支持键名重新映射(Key Remapping in Mapped Types)

映射类型可以根据提供的键生成新的对象类型,而要根据输入来创建新键或是过滤键的话,老版本的Typescript是无能为力的。

// 两例都是根据提供的键生成新的对象类型,无法对键做进一步操作
type Options = {
    [K in "noImplicitAny" | "strictNullChecks" | "strictFunctionTypes"]?: boolean
};

type Partial<T> = {
    [K in keyof T]?: T[K]
};

TypeScript 4.1 允许使用as 字句对键名重新映射。

// 过滤掉传入类型的kind属性
type RemoveKindField<T> = {
    [K in keyof T as Exclude<K, "kind">]: T[K]
};

// 传入类型键名首字母大写,前面加get
type Getters<T> = {
    [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
};

interface Person {
    name: string;
    age: number;
    location: string;
}

type LazyPerson = Getters<Person>;
/*
{
    getName: () => string;
    getAge: () => number;
    getLocation: () => string;
}
*/

条件类型可递归

Typescript 4.1 支持在条件类型的分支中引用自身(递归)

type ElementType<T> =
    T extends ReadonlyArray<infer U> ? ElementType<U> : T;

function deepFlatten<T extends readonly unknown[]>(x: T): ElementType<T>[] {
    // ...
}

// All of these return the type 'number[]':
deepFlatten([1, 2, 3]);
deepFlatten([[1], [2, 3]]);
deepFlatten([[1], [[2]], [[[3]]]]);

参考资料

Typescript 4.2新特性一览

更智能的保留类型别名

Typescript可以为类型声明新的名称,即类型别名

type BasicPrimitive = number | string | boolean

type BasicPrimitiveOrSymbal = BasicPrimitive | symbol

Visual Studio CodeTypeScript Playground 之类的编辑器上,将鼠标悬停于想查看的类型之上时,会展示一个信息面板,显示其类型。鼠标悬停查看BasicPrimitiveOrSymbal

在4.2之前的版本中

image

在4.2中

image

可以看出在4.2版本中,对类型别名进行了保留

信息面板中展示的类型就是TS对此类型的推断,故此规则也适用于编译出的类型声明文件(.d.ts输出)

export type BasicPrimitive = number | string | boolean;
export function doStuff(value: BasicPrimitive) {
    if (Math.random() < 0.5) {
        return Symbal();
    }
    return value;
}

如上函数doStuff的返回值在4.2以下版本将被推断为number | string | boolean | symbol,在4.2中将会被推断为BasicPrimitive | symbol

编译输出的*.d.ts文件,在4.2之前的版本中

export declare type BasicPrimitive = number | string | boolean;
export declare function doStuff(value: BasicPrimitive): string | number | boolean | symbol;

在4.2版本中

export declare type BasicPrimitive = number | string | boolean;
export declare function doStuff(value: BasicPrimitive): symbol | BasicPrimitive;

Rest参数可用于元祖的任何位置

type T1 = [...string[], number] // 任意个string跟着一个number

type T2 = [number, ...string[], number] // string之前各有一个number

在之前版本中Rest参数只能在元祖末尾处使用

type T1 = [number, ...string[]] // number后跟着任意个string

type T2 = [boolean, ...string[], number] // ❌ error: A rest element must be last in a tuple type

在4.0版本中可以通过定义类型别名的方式规避报错,但类型会被推断为Rest参数的类型和其后所有类型的联合类型

type t1 = string[]

type t2 = [boolean, ...t1, number] // 被推断为[boolean, ...(string | number)[]]

虽在4.2版本中Rest参数可用于元祖的任意位置,但仍有一些限制

  • 元祖中只能有一个Rest参数
  • Rest参数后不能出现可选参数
type T1 = [...string[], ...number[]] // ❌ error: A rest element cannot follow another rest element.

type T2 = [...string[], number?] // ❌ error: An optional element cannot follow a rest element.

对第一条规则4.2.2版本仍可以通过定义类型别名的方式规避报错

type T1 = number[]

type T2 = [...string[], ...T] // 被推断为 (string | number)[]

--noPropertyAccessFromIndexSignature标志

开启标志后索引签名中未显式声明的属性通过.操作符访问将会抛出一个错误。具体是否使用看个人习惯

interface Option {
    name: string;
    [x: string]: any;
}

declare const a: Option

let b = a['age']

let c = a.age // ❌ error:  Property 'age' comes from an index signature, so it must be accessed with ['age']

abstract构造符

再类上使用abstract声明,表明类只能被继承,而不能被通过new操作符调用

而这带来的一个问题是,抽象类与new (...args: any) => any不兼容

abstract class Shape {
    abstract name: string;
    abstract getArea(): number;
}

new Shape() // error: Cannot create an instance of an abstract class.

const a: new (...args: any) => any = Shape // ❌ error: Type 'typeof Shape' is not assignable to type 'new (...args: any) => any'.

// InstanceType内置类型也用了new (...args: any) => any实现的
type MyInstance = InstanceType<typeof Shape> // ❌ error: Type 'typeof Shape' is not assignable to type 'new (...args: any) => any'.

而在TS 4.2中abstract可作为构造符使用,由此就可以定义一个获取抽象类示例类型的别名

type AbstractInstanceType<T extends abstract new (...args: any) => any> = T extends abstract new (...args: any) => infer R ? R : any

type MyInstance = AbstractInstanceType<typeof Shape>; // Shape

--explainFiles标志

使用此标志去编译,TS将给出非常详细的关于import信息的说明

tsc --explainFiles

打印例子如下:

node_modules/typescript/lib/lib.d.ts
  Default library
node_modules/typescript/lib/lib.es5.d.ts
  Library referenced via 'es5' from file 'node_modules/typescript/lib/lib.es2015.d.ts'
  Library referenced via 'es5' from file 'node_modules/typescript/lib/lib.d.ts'
node_modules/typescript/lib/lib.es2015.d.ts
  Library referenced via 'es2015' from file 'node_modules/typescript/lib/lib.es2016.d.ts'
node_modules/typescript/lib/lib.es2016.d.ts
  Library referenced via 'es2016' from file 'node_modules/typescript/lib/lib.es2017.d.ts'
node_modules/typescript/lib/lib.es2017.d.ts
...

此外,还可以指定信息的输出位置

# 将信息输出到.text文件
tsc --explainFiles > expanation.txt

# 将信息输出到 VS Code

tsc --explainFiles | code -

改进逻辑表达式中的未调用函数检查

在使用--strictNullChecks时,&&||将进行未调用函数检查

function foo() { return false }

function is_foo_1() { return foo ? 1 : 0 } // ❌ error: This condition will always return true since the function is always defined. Did you mean to call it instead

function is_foo_2() { return foo && 1 } // ❌ error: This condition will always return true since the function is always defined. Did you mean to call it instead

function is_foo_3() { return foo || 1 } // ❌ error: This condition will always return true since the function is always defined. Did you mean to call it instead

解构变量可以显式标记为未使用

在解构的变量名上通过前缀增加下划线_的方式显式声明该变量不使用

let [_first, second] = getValues();

参考资料

Announcing TypeScript 4.2

Typescript 4.0新特性一览

可变元组类型(Variadic Tuple Types)

旧版本Typescript要实现一个concat方法,一般实现方式是:

function concat<T, U>(arr1: T[], arr2: U[]) {
  return [...arr1, ...arr2]
}

这种方式实现的concat方法的返回值类型是一个联合类型的数组(T | U)[]

declare const arr1: number[]
declare const arr2: string[]

// type: (string | number)[]
const arr = concat(arr1, arr2)

如果要对元祖进行concat操作呢

declare const arr1: [number, string]
declare const arr2: [boolean]

// type: (string | number | boolean)[]
const arr = concat(arr1, arr2)

元祖具有特定长度和元素类型,上例的返回值类型显然是不精确的,期望的返回值类型是[number, string, boolean]

要实现期望的结果,在旧版本的TS中只能编写重载

function concat<T, U, V>(arr1: [T, U], arr2: [V]): [T, U, V]
function concat<T, U>(arr1: T[], arr2: U[]) {
  return [...arr1, ...arr2]
}

但如果传入的元祖长度不能确定,我们只能不断的编写重载以尽可能覆盖所有的情况,这显然是不可接受的。

TypeScript 4.0 带来了两个基础更改,并在推断方面进行了改进,从而可以类型化这些内容。

其中一个更改是范型可用于扩展运算符。这意味着可以用范型声明一个可变的元祖。

由此就可以实现一个类型支持更好的concat函数

function concat<T extends unknown[], U extends unknown[]>(t: [...T], u: [...U]): [...T, ...U] {
    return [...t, ...u];
}

declare const arr1: [string, number]
declare const arr2: string[]
declare const arr3: ['hello']

concat(arr1, arr2) // [string, number, ...string[]]
concat(arr1, arr3) // [string, number, 'hello']

另一个更改是旧版本Typescript的rest参数只支持数组类型,且必须放在元祖的最后;而现在可以放在元祖的任意位置。

// 旧版本ts
type t1 = [...string[]]
type t2 = [...[string, number]] // error: A rest element type must be an array type.
type t3 = [...string[], string] // A rest element must be last in a tuple type

不确定长度的数组类型使用扩展运算符,如果不放置于最后,那其后的所有元素都将被推断该数组元素的类型和其后元素类型的联合类型

type t1 = string[]
type t2 = [boolean, ...t1, number] // [boolean, ...(string | number)[]]

标记元祖元素(Labeled Tuple Elements)

如果我们要创建一个图标,可能有如下实现

function createIcon(url: string, size: [number, number]) {
    const [width, height] = size
    return new BMapGL.Icon(url, new BMapGL.Size(width, height));
}

其中的size参数是元祖类型,但我们在调用createIcon方法的时候只清楚size的类型,并不清楚每个元素的意义。在调用时还需要跳转到函数体去查看size每个元素的意义。

image

在Typescript 4.0中,元祖元素可以被标记。上例中的createIcon方法可以这样实现:

function createIcon(url: string, size: [width: number, height: number]) {
    const [width, height] = size
    return new BMapGL.Icon(url, new BMapGL.Size(width, height));
}

这样在调用时就能查看size参数每个元素的意义

image

一些使用规则

在标记一个元组元素时,还必须标记元组中的所有其他元素。

type Size = [width: number, number] // error: Tuple members must all have names or all not have names.

解构标记时无需使用不同名称命名变量。如下例,解构size参数时变量命名无需使用widthheight

function createIcon(url: string, size: [width: number, height: number]) {
    const [w, h] = size
    return new BMapGL.Icon(url, new BMapGL.Size(w, h));
}

使用带标记的元祖可实现重载

image

从构造器函数中推断类属性(Class Property Inference from Constructors)

如下例,在旧版本的Typescript中,当开启了noImplicitAny选项,定义的实例属性areasideLength会报错。因为其没有显式声明类型,从而被推断为any。

class Square { 
    area; 
    sideLength; 
    constructor(sideLength: number) { 
        this.sideLength = sideLength; 
        this.area = sideLength ** 2; 
    } 
}

而在Typescript 4.0中该实例属性的类型会从 constructor函数中推断,areasideLength都被推断为number类型,不会报错。

如果对类实例属性的初始化没有写在constructor函数中,Typescript就无法推断该实例属性的类型。

class Square { 
    // error: Member 'sideLength' implicitly has an 'any' type.
    sideLength; 
    constructor(sideLength: number) { 
        this.initialize(sideLength) 
    } 
    initialize(sideLength: number) { 
        this.sideLength = sideLength; 
    }
} 

此时需要显示声明实例属性的类型,而如果开启strictPropertyInitialization选项(检查已声明但未在构造函数中设置的类属性)还需要显示赋值断言来使类型系统识别类型

class Square {
    sideLength!: number; 
    constructor(sideLength: number) { 
        this.initialize(sideLength) 
    } 
    initialize(sideLength: number) { 
        this.sideLength = sideLength; 
    } 
} 

短路赋值运算符(Short-Circuiting Assignment Operators)

ES2021新增的特性中包含了逻辑赋值运算符(Logical Assignment Operators)的提案。

当变量a为truthy时,将其值设置为b,即等价于a = a && b

a &&= b;

当变量a为falsy时,将其设置为b,即等价于a = a || b

a ||= b;

当变量a为nullish时,将其设置为b,即等价于a = a ?? b

??操作符是ES2020的新增特性Nullish Coalescing

// set a to b only when a is nullish
a ??= b;

Typescript 4.0支持了上述特性

catch子句变量支持声明为unknown(unknown on catch Clause Bindings)

在旧版本的Typescript中,catch子句的变量拥有any类型,且不可以被声明为其他类型

try {

} catch(err: unknown) { // error: Catch clause variable cannot have a type annotation.

}

在Typescript 4.0版本支持将该变量声明为unknown,上例不会报错。

之所以这样做是因为any类型可以兼容其他所有类型,如上例对err变量进行任何操作都不会报类型错误。而unknownany 更安全,因为它会在我们操作值之前提醒我们执行某种类型检查。

try {

} catch(err: unknown) {
    if (err instanceof Error) {
        console.error(err.message)
    }
}

定制 JSX Fragment 工厂函数

旧版本的Typescript便已支持定制JSX工厂函数,可通过jsxFactory选项进行定制。

在Typescript 4.0中支持通过新的jsxFragmentFactory 选项来定制 Fragment 工厂函数。

如下tsconfig.json配置告诉 TypeScript 以与 React 兼容的方式转换 JSX,但将每个工厂函数切换为 h 而不是 React.createElement,并使用 Fragment 而不是 React.Fragment

使用如下tsconfig.json配置

{ 
  "compilerOptions": { 
    "target": "esnext", 
    "module": "commonjs", 
    "jsx": "react", 
    "jsxFactory": "h", 
    "jsxFragmentFactory": "Fragment" 
  } 
} 

编译如下代码

import { h, Fragment } from "preact"; 
let stuff = <> 
    <div>Hello</div> 
</>; 

将输出

"use strict";
exports.__esModule = true;
/** @jsx h */
/** @jsxFrag Fragment */
var preact_1 = require("preact");
var stuff = preact_1.h(preact_1.Fragment, null,
    preact_1.h("div", null, "Hello"));

JSX工厂函数支持使用/** @jsx */注释,去指定当前文件使用的JSX工厂函数。同样,Fragment工厂函数可通过新的/** @jsxFrag */注释去指定。

如下,在文件头部指定当前文件使用的JSX工厂函数和Fragment工厂函数。

通过注释指定的方式比在tsconfig.json文件中配置的优先级高

/** @jsx h */ 
/** @jsxFrag Fragment */ 
import { h, Fragment } from "preact"; 
let stuff = <> 
    <div>Hello</div> 
</>; 

重大更改

lib.d.ts

Typescript 4.0删除了 document.origin,它仅在 IE 的旧版本中有效,而 Safari MDN 建议改用 self.origin。

如下,在Typescript 4.0版本访问documentorigin属性将提示该属性不存在

document.origin // error: Property 'origin' does not exist on type 'Document'

如果要在旧版本的IE使用该属性,需要显式设置

interface Document {
    origin: string
}

console.log(document.origin)

属性重写访问器(反之亦然)会报错(Properties Overriding Accessors (and vice versa) is an Error)

旧版本的Typescript中,子类的实例属性覆盖父类的访问器属性只有在使用useDefineForClassFields选项时才会报错。

class Base { 
    get foo() { 
        return 100; 
    } 
    set foo(val) { 
        // ... 
    } 
} 
class Derived extends Base {
// 旧版本在使用useDefineForClassFields选项会报错 error: 'foo' is defined as an accessor in class 'Base', but is overridden here in 'Derived' as an instance property.
    foo = 10;
} 

而在Typescript 4.0版本中,无论是否使用useDefineForClassFields选项,子类的实例属性覆盖父类的访问器属性(或子类的访问器属性覆盖父类的实例属性)总是报错。

delete 的操作对象必须是可选的

Typescript 4.0版本在启用 strictNullChecks 选项时,使用delete 运算符,操作对象现在必须为 anyunknownnever 或为可选(因为它在类型中包含 undefined)。否则,使用 delete 运算符将会报错。

interface Thing { 
    prop: string; 
    a: unknown;
    b: any;
    c: never;
    d: undefined;
} 
function f(x: Thing) { 
    delete x.prop; // error: The operand of a 'delete' operator must be optional.
    delete x.a
    delete x.b
    delete x.c
    delete x.d
}

参考资料

ahooks源码分析之usePrevious

usePrevious用于保存上一次渲染时的状态。

React官方文档提供了一个实现:

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

usePrevious记录的值初始为空,每轮渲染后记录状态值,这样每次渲染返回的便是上一轮渲染时的值。

react-use同样使用了此实现

ahooks则为用户提供了compare,可以让用户决定是否更新usePrevious记录的值。

import { useRef } from 'react';

export type compareFunction<T> = (prev: T | undefined, next: T) => boolean;

function usePrevious<T>(state: T, compare?: compareFunction<T>): T | undefined {
  const prevRef = useRef<T>();
  const curRef = useRef<T>();

  const needUpdate = typeof compare === 'function' ? compare(curRef.current, state) : true;
  if (needUpdate) {
    prevRef.current = curRef.current;
    curRef.current = state;
  }

  return prevRef.current;
}

export default usePrevious;

ahooks 使用了两个ref,一个记录当前值,一个记录之前的值。不过为什么要这样实现呢?这样实现与react-use的实现方式有什么不同?🤔

在一番试验无果后,随意搜索了下却找到了这个issue

什么?ahooks的实现不符合使用规范😯

issue作者给出的链接中说明了在render时读取或修改ref的值时会进行警告,并且Dan在回复中说明了这样做的原因。

image

大意是在render时读取ref的值和读取一个随机的全局变量一样。读取的值是什么取决于何时调用render。如果React调用在稍微不同的时间渲染,可能会得到不同的结果。

在未来React默认开启Concurrent模式后,ahooks的实现便会出现问题。

issue作者除了给出解释之外,还提供了一个demo。demo中使用的usePreviousStrictMode下有了不同的行为。

不过demo中渲染用的也是legacy模式,那为什么在StrictMode下行为会不同?🤔

打断点调试了一番,发现进入页面时usePrevious居然被调用了两次,导致curRefpreRef记录的状态出现了问题。

在React issue中搜索StrictModetwice等关键字找到了原因,还是我们的Dan神回复的:

image

issue地址

使用了StrictMode且用了Hooks的组件会在开发模式时渲染两次。StrictMode的一个主要目的是方便将现有项目迁移到未来使用concurrent模式的React版本中,会这么设计不奇怪。

至此,作战告捷😊

简单改了下ahooksusePrevious实现。

function usePrevious<T>(state: T, compare?: (prev: T | undefined, next: T) => boolean): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    const needUpdate = typeof compare === 'function' ? compare(ref.current, state) : true;
    if (needUpdate) {
      ref.current = state;
    }
  });

  return ref.current;
}

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.