diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-1.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-1.png new file mode 100644 index 0000000..551f8c4 Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-1.png differ diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-2.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-2.png new file mode 100644 index 0000000..6adde78 Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-2.png differ diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-3.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-3.png new file mode 100644 index 0000000..9914957 Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-3.png differ diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-4.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-4.png new file mode 100644 index 0000000..9de5d25 Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-4.png differ diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-5.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-5.png new file mode 100644 index 0000000..0e515dc Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled-5.png differ diff --git a/assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png new file mode 100644 index 0000000..e84c1a9 Binary files /dev/null and b/assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png differ diff --git a/components/DarkModeBtn.tsx b/components/DarkModeBtn.tsx index 42db2cc..852fffb 100644 --- a/components/DarkModeBtn.tsx +++ b/components/DarkModeBtn.tsx @@ -1,14 +1,11 @@ import classNames from 'classnames'; +import useMounted from 'lib/hooks/useMounted'; import { useTheme } from 'next-themes'; -import { useEffect, useState } from 'react'; import { FiMoon, FiSun } from 'react-icons/fi'; const DarkModeBtn = () => { - const [mounted, setMounted] = useState(false); + const { mounted } = useMounted(); const { systemTheme, theme, setTheme } = useTheme(); - // When mounted on client, now we can show the UI - useEffect(() => setMounted(true), []); - const currentTheme = theme === 'system' ? systemTheme : theme; if (!mounted) diff --git a/components/RUA/RUACodeSandbox.tsx b/components/RUA/RUACodeSandbox.tsx new file mode 100644 index 0000000..61591fe --- /dev/null +++ b/components/RUA/RUACodeSandbox.tsx @@ -0,0 +1,72 @@ +import classNames from 'classnames'; +import useInView from 'lib/hooks/useInView'; +import { useTheme } from 'next-themes'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import RUALoading from './loading/RUALoading'; + +const partten = + /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; +const commonClass = classNames( + 'rounded-lg h-[500px] border-0', + 'overflow-hidden w-full' +); + +type Props = { + url: string; +}; + +const RUACodeSandbox = ({ url }: Props) => { + const isUrl = partten.test(url); + const { systemTheme, theme } = useTheme(); + const currentTheme = theme === 'system' ? systemTheme : theme ?? 'light'; + + const { ref, inView } = useInView(); + const sandUrl = new URL(url); + const embed = sandUrl.pathname.split('/')[2]; + const [src, setSrc] = useState(''); + useEffect(() => { + inView && + setSrc( + `https://codesandbox.io/embed/${embed}?fontsize=14&hidenavigation=1&theme=${currentTheme}&view=preview` + ); + }, [currentTheme, embed, inView]); + + const [load, setLoad] = useState(false); + const handleLoad = useCallback(() => { + setLoad(true); + }, []); + + if (!isUrl) return null; + + return ( + <> +
+
+ +
+ + +
+ + ); +}; + +export default RUACodeSandbox; diff --git a/components/post/PostTOC.module.css b/components/post/PostTOC.module.css deleted file mode 100644 index 36f0d0f..0000000 --- a/components/post/PostTOC.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.head:hover:before { - content: unset !important; -} diff --git a/components/post/PostTOC.tsx b/components/post/PostTOC.tsx deleted file mode 100644 index 00b2176..0000000 --- a/components/post/PostTOC.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { getHeadings } from 'lib/utils'; -import Anchor from 'components/mdx/Anchor'; -import styles from './PostTOC.module.css'; -import classNames from 'classnames'; -import { useCallback, useState } from 'react'; -import { FiChevronDown } from 'react-icons/fi'; - -interface Props { - headings: ReturnType; -} - -const PostTOC = ({ headings }: Props) => { - const [show, setShow] = useState(false); - const handleClick = useCallback(() => setShow((show) => !show), []); - - return ( - <> -
-

- What's inside? - - -

- - -
- - ); -}; - -export default PostTOC; diff --git a/components/post/SlideToc.tsx b/components/post/SlideToc.tsx index 5792339..846870f 100644 --- a/components/post/SlideToc.tsx +++ b/components/post/SlideToc.tsx @@ -1,9 +1,9 @@ -import { useEffect, useState } from 'react'; +import useMounted from 'lib/hooks/useMounted'; +import { useEffect } from 'react'; import tocbot from 'tocbot'; const SlideToc = () => { - const [mounted, setMounted] = useState(false); - useEffect(() => setMounted(true), []); + const { mounted } = useMounted(); useEffect(() => { // Waiting the right time. diff --git a/lib/hooks/useMounted.tsx b/lib/hooks/useMounted.tsx new file mode 100644 index 0000000..cc0036f --- /dev/null +++ b/lib/hooks/useMounted.tsx @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +const useMounted = () => { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + return { + mounted, + }; +}; + +export default useMounted; diff --git a/pages/p/generic-component-encapsulate-reusable-component/hook-form-basic/App.ts b/pages/p/generic-component-encapsulate-reusable-component/hook-form-basic/App.ts new file mode 100644 index 0000000..e6246a8 --- /dev/null +++ b/pages/p/generic-component-encapsulate-reusable-component/hook-form-basic/App.ts @@ -0,0 +1,44 @@ +const app = `import { useForm } from 'react-hook-form'; + +type Pet = 'Cat' | 'Dog'; +type FormData = { + firstName: string; + lastName: string; + favorite: Pet; +}; + +export default function App() { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm(); + const onSubmit = handleSubmit((data) => console.log(data)); + + return ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ ); +}`; +export default app; diff --git a/pages/p/generic-component-encapsulate-reusable-component/index.mdx b/pages/p/generic-component-encapsulate-reusable-component/index.mdx new file mode 100644 index 0000000..6a7459f --- /dev/null +++ b/pages/p/generic-component-encapsulate-reusable-component/index.mdx @@ -0,0 +1,338 @@ +--- +title: 组件泛型实例-封装可复用的表单组件 +date: '2022-08-12' +tags: [TypeScript, React] +--- + +export const meta = { + title: '组件泛型实例-封装可复用的表单组件', + date: '2022-08-12', + tags: ['TypeScript', 'React'], +}; + +import Layout from 'layouts/MDXLayout'; +import dynamic from 'next/dynamic'; +import Image from 'components/mdx/Image'; +import image0 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import image1 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import image2 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import image3 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import image4 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import image5 from 'assets/images/p/generic-component-encapsulate-reusable-component/Untitled.png'; +import app from './hook-form-basic/App.ts'; +import app2 from './react-generic/App.ts'; +import child from './react-generic/Child.ts'; + +export const RUASandpack = dynamic(() => import('components/RUA/RUASandpack')); +export const RUACodeSandbox = dynamic(() => + import('components/RUA/RUACodeSandbox') +); + +export default ({ children }) => {children}; + +当前很多 UI 库都会为我们集成可复用的 Form 组件,并且是开箱即用。但有时候我们往往可能需要为自己的组件集成 Form。单纯的手动管理所有的状态可能不是件理想的活,尤其是表单验证。 + +[React Hook Forms](https://react-hook-form.com/) 为我们提供了完善的状态管理,并且可以集成到任何组件中去。 + +你可能会问,如今已经有了像是 MUI、Ant Design 等此类优秀的组件库,为什么还需要使用 React Hook Forms 来管理表单。 + +[MUI: The React component library you always wanted](https://mui.com/zh/) + +虽然一些优秀的成熟组件库会为我们提供良好的表单解决方案,但它终究需要与组件库一起使用。而并非只是提供表单的状态管理,并没有完全的与组件库解耦合。 + +同时,当我们使用诸如 [Daisyui](https://daisyui.com/) 等此类的 CSS 组件时,它们是与状态完全解耦合的。我们需要自己为其维护状态。 + +## Hook our form + +对于一个表单来说,提供的表单项越多,所需要的状态管理就越繁琐。不仅仅是状态管理,后续的表单验证才是一个表单的核心所在。 + +React Hook Forms 对 TypeScript 支持良好,有了 TypeScript 我们就可以在开发时验证表单类型。而表单的数据类型也是后续封装通用组件较为繁琐的一个地方。 + + + +React Hook Forms 在使用方面,使用了一个 `register` 函数代替了我们为每个表单项管理状态的步骤。从写法上就可以看出,这个函数返回了我们的表单所需要的属性,以及其状态。 + +```tsx + +``` + +在表单提交方面,`handleSubmit` 方法接受一个回调,其参数就是表单输入后的状态。 + +```tsx +const onSubmit = handleSubmit((data) => console.log(data)); +``` + +表单验证通过后,就可以成功调用这个函数,以实现我们的表单提交。 + +这是一段最基础的用法,没有表单验证提示,仅仅只是接受任何用户输入的数据。并且同样的组件也没有实现复用。 + +## Input 组件 + +封装一个可复用的 `Input` 组件可能是再简单不过的事情了,对于其参数类型,主要部分还是来自于 `HTMLInput` 。我们只需要个别定义的属性,再利用剩余参数将其全部赋值给真正的 `input` + +```tsx +export type FormInputProps = { + label?: string | undefined; +} & DetailedHTMLProps, HTMLInputElement>; +``` + +```tsx +const Input = ({ name, label, ...rest }: FormInputProps) => { + return ( + <> + + + + + + + ); +}; +``` + +用起来自然也是和常见的组件一样方便: + +```tsx +
+ +
+ +
+ +
+``` + +但是如果仅仅只是这样,我们的组件还不能与 React Hook Forms 一起工作。因为其核心部分 `register` 函数还无法传递给我们的 `Input` 组件。也就是说我们的组件现在还是不可控的,这时候再尝试提交就会发现无法获取其状态。 + +无法获取其状态 + +当然我们不能简单的将 `register` 函数塞给 `Input` 组件,因为它还没有合适的签名。`register` 函数会根据表单的数据签名和不同的表单项来实现自己的签名。 + +从 `register` 函数的签名中就可以看出,它接受一个泛型,该泛型就是对应的表单项类型。 + +```tsx +register: <"firstName">(name: "firstName", options?: ...) +``` + +也就是 `FormData` 中的 `firstName` : + +```tsx +type FormData = { + firstName: string; + lastName: string; + favorite: Pet; +}; +``` + +没错,要想正确的给组件中的 `register` 函数签名,我们就得给我们的函数式组件上个泛型。 + +## 泛型 + +在考虑给组件添加一个泛型之前,需要先简单的了解下泛型是如何工作的。 + +一个函数的泛型可以非常的简单,它代表了一个任意的类型值(当然也可以对其进行约束)。并根据指定的参数为泛型时,自动推断该类型值。 + +```ts +const logAndReturn = (target: T) => { + console.log(target); + return target; +}; + +// const logAndReturn: <42>(target: 42) => 42 +logAndReturn(42); +// const logAndReturn: <"42">(target: "42") => "42" +logAndReturn('42'); +``` + +### 类型别名中的泛型 + +类型别名中的泛型与函数不同的是,它需要手动传递一个函数的泛型值(或来自其他地方的泛型),并根据该泛型来决定其值。并且如果泛型有约束的话,还需要符合其约束。 + +例如,我们有一个描述个人的类型别名: + +```ts +type Person = { + name: string; + age: number; + favorite: T; +}; +``` + +而我们需要编写一个函数,根据其 `favorite` 来决定打印的值。函数大概长这样: + +```tsx +const sayIt = (p: Person) => { + const type = typeof p.favorite; + switch (type) { + case 'string': + console.log(`My favorite word is: ${p.favorite}`); + return; + case 'number': + console.log(`My favorite number is: ${p.favorite}`); + return; + } +}; +``` + +当指定参数为 `p: Person` 时,就需要将函数的泛型传递给类型别名。且类型别名中的泛型约束在了 `` 之间,函数必须保证使其子类型。否则就会提示无法满足其类型。 + +未约束的泛型 + +和参数类型,泛型也是向下兼容的,只要保证其类型是子类型即可。也就是说这样也是可以的 `const sayIt = (p: Person) => {}` 。数字 42 是 `number` 类型的子类型。 + +随后在调用函数时,就能发现泛型给我们带来的作用了。 + +传递数字给泛型 + +传递字符串给泛型 + +### React 中的泛型 + +我们的 React 函数组件也是一个函数,对于泛型的规则同样适用。 + +来看一个简单的小组件,该组件可以以一个常见的对象类型 `Record` 来根据指定的 key 访问其值,并展示在 DOM 上。 + + + +例如,这样的一个值: + +```tsx +const testData = { + firstName: 'xfy', + lastName: 'xfyxfy', +}; +``` + +当传递其对应的 key 时,我们的子组件就会展示对应的属性。也就是 `data[key]` ,这是再简单不过的一个属性访问方式了。 + +```tsx + +``` + +但不仅如此,我们还希望我们的子组件能够根据已经存在的值,推断出我们能够传递的 key。 + +类型推断 + +这正是泛型的作用。 + +首先,我们子组件的参数签名必然需要一个泛型。并且我们将泛型约束在为一个常见的对象 `Record`,且不在乎属性值具体是什么类型(unknown)。 + +```tsx +type Props> = { + name: keyof T; + data: T; +}; +``` + +这便是我们组件的参数具体的签名。还记得上述类型别名需要将函数的泛型传递给它吗?接下来就是要给函数式组件添加一个泛型,并将其传递给 `Props` 。 + +我们的组件也是一个标准的函数,所以接下来就简单多了。只需要将泛型正确的约束,并传递给别名即可。 + +```tsx +const Child = >({ + name, + data, +}: Props) => {}; +``` + +```tsx +const Child = >({ name, data }: Props) => { + const [showName, setShowName] = useState(); + const valid = () => { + console.log(data[name]); + setShowName(data[name]); + }; + + return ( + <> +
{name}
+ + +
{JSON.stringify(showName)}
+ + ); +}; +``` + +## 带有泛型的 Input 组件 + +`register` 函数对表单项的验证与上述较为类似,它也会根据表单项的 key 来决定传递对应的 name。为了满足 `register` 函数,可复用的 Input 组件就得需要一个泛型,用来接受不同的表单数据类型。 + +React hook forms 为我们提前准备好了适用于 `register` 函数的类型别名 `UseFormRegister` ,它会接受一个泛型,该泛型就是我们的表单数据类型。 + +所以 `register` 函数的签名看起来就像这样 `register?: UseFormRegister;` 这里的 `T` 就是我们的表单类型。但是我们还不知道传入当前组件中的表单类型是什么,所以我们的组件参数签名也需要一个泛型。 + +所以这里我们的组件参数看起来是这样的: + +```tsx +export type FormInputProps = { + name: Path; + label?: string | undefined; + rules?: RegisterOptions; + register?: UseFormRegister; +} & DetailedHTMLProps, HTMLInputElement>; +``` + +值得注意的是,这里给 `input` 使用的 name 属性。因为 `register` 函数注册时使用的名称需要确保为表单类型的中的一个。所以这里需要使用 React hook forms 导出的 `Path` 类型,以配合 `register` 函数。 + +这里就和上述泛型组件很相似了,接下来要做的就是将组件的泛型传递给参数签名: + +```tsx +const Input = >({ + name, + label, + ...rest +}: FormInputProps) => {}; +``` + +这里给组件的泛型小小的约束一下,我们希望传递过来的表单类型是一个普通的对象结构 `>` 。 + +不仅如此,还不能忘了 `register` 函数还需要注册在 DOM 上。 + +```tsx + +``` + +得益于泛型的功劳,我们将 `register` 函数传递给 `Input` 组件时,我们的组件就知道了这次表单的类型。并且确定了 `name` 属性的类型。 + +类型推断 + +这是因为 `register` 函数本身的签名:`const register: UseFormRegister` 。这才使得我们的组件成功接受到了泛型。 + +再添加一些 `rules` 以及验证未通过时的提示,这样一个可复用的 React hook form 组件就封装好了。 + +```tsx + +``` + + diff --git a/pages/p/generic-component-encapsulate-reusable-component/react-generic/App.ts b/pages/p/generic-component-encapsulate-reusable-component/react-generic/App.ts new file mode 100644 index 0000000..4224cbe --- /dev/null +++ b/pages/p/generic-component-encapsulate-reusable-component/react-generic/App.ts @@ -0,0 +1,22 @@ +const app = `import "./styles.css"; +import Child from "./Child"; + +const testData = { + name: "xfy", + age: 18 +}; + +export default function App() { + return ( +
+

Hello CodeSandbox

+

Start editing to see some magic happen!

+ +
+ +
+
+ ); +}`; + +export default app; diff --git a/pages/p/generic-component-encapsulate-reusable-component/react-generic/Child.ts b/pages/p/generic-component-encapsulate-reusable-component/react-generic/Child.ts new file mode 100644 index 0000000..0e3de8a --- /dev/null +++ b/pages/p/generic-component-encapsulate-reusable-component/react-generic/Child.ts @@ -0,0 +1,27 @@ +const child = `import { useState } from "react"; + +type Props> = { + name: keyof T; + data: T; +}; + +const Child = >({ name, data }: Props) => { + const [showName, setShowName] = useState(); + const valid = () => { + console.log(data[name]); + setShowName(data[name]); + }; + + return ( + <> +
{name}
+ + +
{JSON.stringify(showName)}
+ + ); +}; + +export default Child;`; + +export default child;