susucain / blog Goto Github PK
View Code? Open in Web Editor NEW记录工作、学习之余的总结和思考
License: MIT License
记录工作、学习之余的总结和思考
License: MIT License
SSR服务端渲染(英语:server side render)。服务端渲染把数据的初始请求放在了服务端,服务端收到请求后,把数据填充到模板形成完整的页面,由服务端把渲染的完整的页面返回给客户端。
服务端直出HTML
会让首屏较快展现,且利于SEO
。但所有页面的加载都需向服务端请求,如果访问量较大,会对服务器造成压力。此外,页面之间的跳转,页面局部内容的变动都会引起页面刷新,体验不够友好。
CSR客户端渲染(英文:Client Side Rendering)。
它是目前 Web 应用中主流的渲染模式,一般由 Server 端返回初始 HTML 内容,然后再由JS 去异步加载数据,再完成页面的渲染。客户端渲染模式中最流行的开发模式当属SPA(单页应用)。这种模式下服务端只会返回一个页面的框架和js 脚本资源,而不会返回具体的数据。
只有首次进入或刷新时需要请求服务器,页面之间的跳转由JS
脚本完成,响应较快。但由于服务端只返回一个空节点的HTML
,页面内容的呈现需等待JS
脚本加载执行完毕,首屏时间较长,对SEO
也不友好。
相比于客户端渲染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
便是如此实现的
服务端已将数据写入script
标签中,客户端渲染时便可直接用该数据进行渲染。
如果只是要做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
目前next.js
和egg-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-loader
的module.exportOnlyLocals
选项提供了仅导出标识符映射,而不签入css
的功能
module.exports = {
module: {
rules: [
{
test: /\.css$/i,
loader: "css-loader",
options: {
modules: {
exportOnlyLocals: true,
},
},
},
],
},
};
对一个较大项目来说,我们访问其中的一个页面时,只需加载当前页面的代码,其他页面的代码只要在访问的时候再加载即可。这可以有效减少访问一个页面时需加载的js
文件体积,提高页面响应速度。
在一个SPA
应用中实现按需加载,只需使用dynamic import
语法,webpack
支持该特性。使用动态import语法导入的模块会被单独打包到一个文件。
HTML
HTML
,客户端接管后由于异步JS
代码尚未加载,会先展示中间状态(一般中间状态会先渲染loading),这样双端的初次渲染结果就不一致了JS
代码,即script
标签,无需客户端动态创建针对第一点,其实服务端无需按需加载,应直接渲染出按需加载的组件;
第二点,为保证双端初次渲染结果一致,客户端应该等待当前页面按需加载的异步JS
代码下载后再进行渲染;
第三点,为使服务端渲染的HTML
能够包含按需加载的JS
代码的script
,需要获取到当前页面按需加载的组件名,和组件名对应JS
路径名的映射。在直出HTML时将当前页面按需加载的script
标签拼接进去。
react-loadable提供了上述问题的解决方案
按需加载的组件使用Loadable
包裹,其中modules
和webpack
选项标识组件加载的是哪个模块,这样服务端就能根据渲染的组件获取到需加载的模块。
如果使用babel
插件react-loadable/babel
,便无需使用modules
和webpack
选项。
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
打包后的bundles
的映射,服务端可据此判断应直出的scirpt
const ReactLoadableSSRAddon = require('react-loadable-ssr-addon');
module.exports = {
entry: {
// ...
},
output: {
// ...
},
module: {
// ...
},
plugins: [
new ReactLoadableSSRAddon({
filename: 'react-loadable.json',
}),
],
};
将渲染的组件用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();
}
前端路由的原理是根据当前页面路由渲染不同的组件,这一过程由JavaScript
完成,无需经过后台。传统的多页应用每个路由对应一个HTML
文件,跳转到某个路径时,浏览器要向服务端请求对应的静态文件资源。采用前端路由则省去了这一过程,提升了性能。
前端如果使用history
路由,在刷新页面或直接输入地址访问时浏览器会向服务端请求对应路径下的资源,如果这时服务端没有路由匹配则会返回404。
所以,服务端对单页应用的主要处理是对返回404的情况统一返回单页应用的index.html
文件。
注意:配置后需前端针对匹配不到路由的情况写一个404页面,否则会直接白屏
前端使用BrowserRouter
路由,如果前端包不部署在二级域名下只需将package.json文件的homepage
字段指定为/
就可以了(create-react-app
脚手架使用package.json
文件的homepage
字段作为Webpack
的publicPath
配置项)
如果前端包需在二级域名下访问,则需指定homepage
字段为二级路由名,如/sub
。还需指定前端路由的basename
属性
<BrowserRouter basename="/sub">
使用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文件
如图中,访问地址是/scooper-ifpc-web/lib/prompt/prompt.css
,nginx会默认在自己的静态资源文件中查找
/usr/share/nginx/html/scooper-ifpc-web/lib/prompt/prompt.css
如找到,就返回这个文件。
Tomcat
只需在配置文件web.xml
文件中写入如下配置即可
<error-page>
<error-code>404</error-code>
<location>/index.html</location>
</error-page>
当请求在服务端没有路由匹配时返回单页应用的index.html
文件
使用Node.js
的Express
框架只需引入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', '*/*']
之一。
之前做过的项目在通讯录成员有1000个时,网页加载要30s才能加载完全。
想到可能的原因有两种
打开Network
面板,刷新页面重新加载后发现接口的响应时间只有1.43秒,所以排除是接口响应时间过长导致的问题。
排除掉接口响应时间长的问题后,用Performance
面板分析是否是脚本执行时间过长导致的问题。
点击Record
按钮后重新加载通讯录,待通讯录加载完毕后结束录制,查看。
其中黄色部分是脚本执行的时间,发现脚本执行时间占了大部分。
至此,确定是脚本执行时间过长阻塞了浏览器渲染。
查看在此期间的函数调用栈,发现createNodeCallback
函数执行时间长达29.75秒,而这个函数调用了数次apply
方法和addDiyDom
方法。
找到函数的定义位置,发现函数执行了1000次的循环,循环调用addDiyDom
方法。如果setting.callback.onNodeCreated
存在还会触发1000次NODECREATED
事件。而这两项都是挂在setting
,也就是用户的配置上。
由于项目的配置中没有onNodeCreated
项,所以在代码中注释掉addDiyDom
方法,再次用Performance
面板分析
可以看到脚本的执行时长大大减少。
查看addDiyDom
方法
该方法查找了页面上的DOM元素,并直接对页面上的DOM元素进行了修改;而在JS中对DOM的操作最是损耗性能。
至此,问题原因定位完毕,由于zTree
插件连续调用1000次对页面上的dom节点直接进行修改的addDiyDom
方法,造成了脚本执行时间过长,页面卡顿的性能问题。
查看源码发现createNodeCallback
方法是在appendParentULDom
方法之后调用,而appendParentULDom
方法所做的正是把zTree插件生成的dom树插入到页面中。所以,用户定义的addDiyDom
方法就只能对页面上的DOM直接进行操作了。
由于业务需求,需要在人员节点上增加两个按钮。可不可以在生成节点的时候就吧自定义按钮插入进去呢?这样就可以避免直接操作页面上DOM
的性能问题
在官网的API中也没有找到可以在生成节点的时候把用户自定义的节点插入的方法。只好再翻一下源码。
找到生成dom节点的位置
在这些方法中一个个找可以插入节点的地方,zTree
的节点结构是固定的,外层是一个li
元素,第一个子节点span
元素用作(展开/收起)的按钮,接下来是一个a
标签。a
标签中第一个元素是节点名称前的图标,第二个是展示用户配置数据的节点名称data[i].name
源码中第一个方法生成li
元素的开始标签部分;第二个方法生成span.switch元素;第三个方法中查询用户配置,如果用户配置了setting.check.enable为true
,则会在a元素前插入一个选择框;第四个方法生成a
标签的开始部分;第五个方法从名称上看是向a
元素前插入节点,前一个方法刚好生成了a
标签的开始部分,如果这个方法可以插入节点,问题就能解决。似乎看到了一丝希望。
断点打到getInnerBeforeA
内部
啊嘞嘞,这个函数要想起作用,必须_init.innerBeforeA
数组中有方法才行,那有什么办法可以向_init.innerBeforeA
数组push
函数?
在文件中查找innerBeforeA
看来要想往_init.innerBeforeA
数组push
函数得调用data.addInnerBeforeA
才行,那data
这个对象有没有暴露给用户呢?
通过VS Code
查找这个对象的引用
果然是有暴露给用户的~
这样就可以在zTree
初始化前自定义一个方法,将我需要定制的节点插入a
元素中。
在该函数中打断点查看调用栈,找到传入的三个参数setting
、node
、html
。setting
是用户的配置。用户传入的对应数据上的属性会挂在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
元素的第一个子元素。
打开Performance
面板进行分析
可以看到脚本的执行的时长仍在1秒左右,至此,问题解决
zTree
插件提供的addDiyDom
方法在大数据量的加载时会导致显著的性能问题,因为该方法添加自定义节点时只能直接对页面上的DOM
直接进行操作。
如果用户配置了onNodeCreated
方法,且在该方法中进行了DOM操作,在大数据量的加载中也会导致显著的性能问题,原因与addDiyDom
方法相同。
添加自定义节点可以使用zTree
的内部方法
addInnerBeforeA
插入的节点作为a
元素的第一个子元素addInnerAfterA
插入的节点作为a
元素的最后一个子元素addAfterA
插入的节点作为a
元素后的兄弟元素如果是在鼠标悬浮时才显示用户的自定义元素,可使用setting.view.addHoverDom
和setting.view.removeHoverDom
配置项
加载1000个节点,js脚本的执行时间已经达到了1秒,如果要求加载的数据更多,或有进一步的优化要求就只能考虑分页加载了
首先看这样一个demo
import React from 'react';
function App() {
return (
<p style={{ background:'cyan; xxx' }}>
App
</p>
);
}
export default App;
这个demo在服务端渲染时,刷新页面会发现p
元素的背景色设置成功了。
但当此demo在客户端渲染时(多数SSR框架比如Next.js,在路由跳转时都采用客户端渲染)背景色却没设置成功。
到底是什么的不同造成了这样的差异呢?
在服务端渲染时,node端将调用renderToString()
或renderToNodeStream
方法生成HTML
字符串。在浏览器访问时服务端直出HTML
字符串,通过React.hydrate
比较双端渲染结果是否一致,一致则直接使用服务端渲染的结果,并在已有标记上绑定事件监听器。
可以看到相比于客户端渲染,整个服务端渲染的流程中最大的不同是少了真实DOM
的渲染。
接下来分析虚拟DOM映射到真实DOM的过程,也就是completeWork
这个方法的调用栈,重点查看style
属性设置的方法
这里其实从方法名就能看出设置style
属性的是哪个方法
可以看到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')
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;
在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
的依赖传空数组也能实现持久化函数,但无法获取实时的值)
useEffect
中,为什么usePersistFn
不这样实现?参见这个issue
如果在子组件的useEffect
回调函数中调用usePersistFn
就会出现问题。因为渲染时会先执行子组件的useEffect
,后执行父组件自定义hooks的useEffect
。
demo参见在useEffect中更新fnRef
它的语法与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新增了几个预定义的类型别名,分别是Uppercase
、Lowercase
、Capitalize
、Uncapitalize
。 分别是大写、小写,首字母大写,首字母小写。
type Test<T extends string> = `${Uppercase<T>} ${Lowercase<T>} ${Capitalize<T>} ${Uncapitalize<T>}`;
type T = Test<'test'>
// TEST test Test test
类比Typescript 4.0推出的可变元祖类型,其实模板字符串也可以叫做“可变字符串类型”。此特性的推出大大增强了对字符串字面量的操作。
待补充,此特性还可被用于推断中,再参考一下微信公众号那篇文章
映射类型可以根据提供的键生成新的对象类型,而要根据输入来创建新键或是过滤键的话,老版本的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可以为类型声明新的名称,即类型别名
type BasicPrimitive = number | string | boolean
type BasicPrimitiveOrSymbal = BasicPrimitive | symbol
在Visual Studio Code
或 TypeScript Playground
之类的编辑器上,将鼠标悬停于想查看的类型之上时,会展示一个信息面板,显示其类型。鼠标悬停查看BasicPrimitiveOrSymbal
:
在4.2之前的版本中
在4.2中
可以看出在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;
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参数可用于元祖的任意位置,但仍有一些限制
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)[]
开启标志后索引签名中未显式声明的属性通过.
操作符访问将会抛出一个错误。具体是否使用看个人习惯
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
声明,表明类只能被继承,而不能被通过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
使用此标志去编译,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();
旧版本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)[]]
如果我们要创建一个图标,可能有如下实现
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每个元素的意义。
在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
参数每个元素的意义
在标记一个元组元素时,还必须标记元组中的所有其他元素。
type Size = [width: number, number] // error: Tuple members must all have names or all not have names.
解构标记时无需使用不同名称命名变量。如下例,解构size
参数时变量命名无需使用width
和height
function createIcon(url: string, size: [width: number, height: number]) {
const [w, h] = size
return new BMapGL.Icon(url, new BMapGL.Size(w, h));
}
使用带标记的元祖可实现重载
如下例,在旧版本的Typescript
中,当开启了noImplicitAny
选项,定义的实例属性area
和sideLength
会报错。因为其没有显式声明类型,从而被推断为any。
class Square {
area;
sideLength;
constructor(sideLength: number) {
this.sideLength = sideLength;
this.area = sideLength ** 2;
}
}
而在Typescript 4.0中该实例属性的类型会从 constructor
函数中推断,area
和sideLength
都被推断为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;
}
}
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支持了上述特性
在旧版本的Typescript中,catch子句的变量拥有any
类型,且不可以被声明为其他类型
try {
} catch(err: unknown) { // error: Catch clause variable cannot have a type annotation.
}
在Typescript 4.0版本支持将该变量声明为unknown
,上例不会报错。
之所以这样做是因为any
类型可以兼容其他所有类型,如上例对err
变量进行任何操作都不会报类型错误。而unknown
比 any
更安全,因为它会在我们操作值之前提醒我们执行某种类型检查。
try {
} catch(err: unknown) {
if (err instanceof Error) {
console.error(err.message)
}
}
旧版本的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>
</>;
Typescript 4.0删除了 document.origin
,它仅在 IE 的旧版本中有效,而 Safari MDN 建议改用 self.origin。
如下,在Typescript 4.0版本访问document
的origin
属性将提示该属性不存在
document.origin // error: Property 'origin' does not exist on type 'Document'
如果要在旧版本的IE使用该属性,需要显式设置
interface Document {
origin: string
}
console.log(document.origin)
旧版本的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
选项,子类的实例属性覆盖父类的访问器属性(或子类的访问器属性覆盖父类的实例属性)总是报错。
Typescript 4.0版本在启用 strictNullChecks
选项时,使用delete
运算符,操作对象现在必须为 any
、unknown
、never
或为可选(因为它在类型中包含 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
}
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在回复中说明了这样做的原因。
大意是在render
时读取ref
的值和读取一个随机的全局变量一样。读取的值是什么取决于何时调用render。如果React
调用在稍微不同的时间渲染,可能会得到不同的结果。
在未来React
默认开启Concurrent模式后,ahooks
的实现便会出现问题。
issue
作者除了给出解释之外,还提供了一个demo。demo中使用的usePrevious
在StrictMode
下有了不同的行为。
不过demo中渲染用的也是legacy
模式,那为什么在StrictMode
下行为会不同?🤔
打断点调试了一番,发现进入页面时usePrevious
居然被调用了两次,导致curRef
和preRef
记录的状态出现了问题。
在React issue中搜索StrictMode
、twice
等关键字找到了原因,还是我们的Dan神回复的:
使用了StrictMode
且用了Hooks
的组件会在开发模式时渲染两次。StrictMode
的一个主要目的是方便将现有项目迁移到未来使用concurrent
模式的React版本中,会这么设计不奇怪。
至此,作战告捷😊
简单改了下ahooks
的usePrevious
实现。
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;
}
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.