React Hook + TypeScript + styled-component 建站

JavaScript 同时被 2 个专栏收录
13 篇文章 10 订阅
101 篇文章 2 订阅

技术选型

Vue 与 React 的对比

  • 组件化
    Vue 的组件化是将 UI 结构(template)、UI 样式(style)、数据与业务逻辑(script)都放在一个 .vue 的文件中,运行前 .vue 文件会被编译成真正的组件;
    React 的组件化是直接通过 JS 代码的形式实现组件
  • 模板引擎
    Vue的视图模板使用类 HTML 的写法加上属性与指令,多数情况下要比 React 的 JSX 写法清晰且开发效率高,但是在复杂场景下,Vue 的写法有时会比 React 写起来更麻烦
  • 数据监听
    Vue 使用代理/拦截的方式使得我们直接修改 data 就可以,但 React 需要使用 setState API 改变数据

项目构建

目录结构

├─ mock     #数据模拟
├─ public   #静态
├─ scripts  #脚本
└─ src
    ├─ common      #工具库
    ├─ components  #组件
    ├─ hooks       #钩子
    ├─ pages       #页面
    ├─ styles      #样式
    └─ router      #路由

技术栈

  • 开发框架:React
  • 构建工具:Webpack
  • 类型检查:TypeScript
  • 日志埋点:@baidu/bend-sdk
  • 视图样式:styled-components
  • 状态管理:React-hook/useReducer
  • 数据请求:Umi-hook/useRequest + axios
  • 规范检测:Eslint + prettier + husky + lint-staged + commitlint

代码规范化提交

  • husky 注册 git 的钩子函数保证在 git 执行 commit 时调用代码扫描的动作
  • lint-staged 保证只对当前 add 到 stage 区的文件进行扫描
  • prettier自动格式化代码
  • eslint按照配置扫描代码
  • @commitlint/cli 规范 commit 提交
  • @commitlint/config-conventional commtlint 通用配置

工作流

  • 待提交的代码git add添加到暂存区
  • 执行git commit
  • husky注册在git pre-commit的钩子函数被调用,执行lint-staged
  • lint-staged 取得所有被提交的文件依次执行写好的任务(ESLint 和 Prettier)
  • 如果有错误(没通过ESlint检查)则停止任务,同时打印错误信息,等待修复后再执行git commit
  • 成功 commit,可 push 到远程

package.json配置如下:

{
  ...
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged",
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  },
  "lint-staged": {
    "src/**/*.{jsx,js,tsx,ts}": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  }
}

.prettierrc.js

module.exports = {
    "printWidth": 100, // 一行的字符数,如果超过会进行换行,默认为80
    "tabWidth": 4,
    "useTabs": false, // 注意:makefile文件必须使用tab
    "singleQuote": true,
    "semi": true,
    "trailingComma": "es5", //是否使用尾逗号,有三个可选值"<none|es5|all>"
    "bracketSpacing": true, //对象大括号之间是否有空格,默认为true,效果:{ foo: bar }
    "endOfLine": "auto",
    "arrowParens": "avoid"
};

.eslintrc.js

module.exports = {
  "root": true,
  "env": {
    "browser": true,
    "node": true,
    "es6": true,
    "jest": true,
    "jsx-control-statements/jsx-control-statements": true
  },
  "parser": "@typescript-eslint/parser",
  "parserOptions": {
    "sourceType": 'module',
    "ecmaFeatures": {
      "jsx": true,
      "experimentalObjectRestSpread": true
    }
  },
  "globals": {
    // "wx": "readonly",
  },
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:jsx-control-statements/recommended", // 需要另外配合babel插件使用
    "prettier"
  ],
  "overrides": [
    {
      "files": ["**/*.tsx"],
      "rules": {
          "react/prop-types": "off"
      }
    }
  ],
  "settings": {
    "react": {
      "version": "detect"
    }
  },
  "plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-control-statements", "prettier"],
  "rules": {
    "prettier/prettier": 1,
    "no-extra-semi": 2, // 禁止不必要的分号
    "quotes": ['error', 'single'], // 强制使用单引号
    "no-unused-vars": 0, // 不允许未定义的变量
    "jsx-control-statements/jsx-use-if-tag": 0,
    "react-hooks/rules-of-hooks": "error", // 检查 Hook 的规则
    "react-hooks/exhaustive-deps": "warn" // 检查 effect 的依赖
  }
};

.eslintignore/.prettierignore

**/*.js
!src/**/*.js

.commitlintrc.js

module.exports = {
    extends: ['@commitlint/config-conventional']
};

提交需遵循 conventional commit 格式,即:

type(scope?): subject

e.g. feat: 教培PC框架搭建(cvi-3000)

type 可以是:

  • feat:新功能(feature)
  • upd:更新某功能
  • fix:修补bug
  • docs:文档(documentation)
  • style: 格式(不影响代码运行的变动)
  • refactor:重构(即不是新增功能,也不是修改bug的代码变动)
  • test:增加测试
  • chore:构建过程或辅助工具的变动

样式方案

styled-components

styled-comonents官方文档

是一个 CSS in JS 的类库,就是可以在 JS 中写 CSS 语法
使用 Sass/Less 等预处理语言需要在 Webpack 中进行各种 loader 的配置
而 styled-components 只需直接引用

import styled from 'styled-components';
  • 样式化组件,主要作用是编写实际 CSS 代码来设计组件样式,无需组件和样式之间的映射,创建后实际就是一个 React 组件
  • 使用后不再需要使用 className 来控制样式,而是写成更具语义化的组件
  • 编译后的节点会随机生成 class,可以避免全局污染,但会增加维护难度

解决方案:在 babel 配置中加入 styled-components 插件

babel-plugin-styled-components

use: [
 {
    loader: 'babel-loader',
    options: {
      ...
      plugins: [
        'babel-plugin-styled-components',
        ...
      ]
    }
  },
  ...
]

编译后:


基本使用

// src/components/styles/index.ts
...
interface ICardProps {
    type?: string;
}
...
const typeMap = (type: string | undefined) => {
    switch (type) {
        case 'b':
            return 'block';
        case 'i':
            return 'inline-block';
        case 'f':
            return 'flex';
        case 'n':
            return 'none';
        default:
            return 'block';
    }
};
/**
 * 卡片容器
 */
const Card = styled.div`
    display: ${(props: ICardProps) => typeMap(props.type)};
    padding: 24px 24px 15px;
    background-color: #fff;
`;
Card.defaultProps = {
    type: 'f',
};
...
// src/pages/shop/index.tsx
import styled from 'styled-components';
...
const Shop = () => {
	...
	return (
		<Card type="b">
			...
		</Card>
	);
}

全局样式

// src/style.ts
import { createGlobalStyle } from 'styled-components';

export const GlobalStyle = createGlobalStyle`
    html,body,div,span,applet,object,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video,button {
   margin: 0;
    padding: 0;
    border: 0;
    font-size: 100%;
    font: inherit;
    font-weight: normal;
    vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section {
    display: block;
}
ol,ul,li {
    list-style:none;
}
...
`;
...
import { GlobalStyle } from '@/style';
...

const App: React.FC = () => {
	return (
        <Router>
            <GlobalStyle />
            ...
        </Router>
    );
};

export default App;

代码片段

// src/components/styles/snippet.ts
import { css } from 'styled-components';

const mask = css`
    &::after {
        content: '';
        position: absolute;
        left: 0;
        right: 0;
        top: 0;
        bottom: 0;
        background-image: radial-gradient(50% 129%, rgba(0, 0, 0, 0) 50%, rgba(0, 0, 0, 0.1) 100%);
    }
`;
...
export default {
    mask,
    ...
};
// src/components/styles/index.ts
import styled from 'styled-components';
import s from './snippet';
...
const Img = styled.div`
	...
	${(props: ICardImgProps) => (props.mask ? s.mask : '')}
	...
`

类型系统

TypeScript 微软开发的开源编程语言

TypeScript官方文档

是 JS 的一个超集,主要提供类型系统以及对尚未正式发布的 ECMAScript 新特性的支持,最终会被编译成纯 JavaScript 代码

优点:

  • 编译阶段就能发现大部分错误
  • 增加代码的可读性和可维护性,大部分函数看看类型定义就知道如何使用
  • VSCode 对 TS 提供代码补全、接口提示、跳转定义等功能

缺点:

  • 开发时要写很多类型定义,短期来看会增加开发成本,但对长期维护项目来看可以降低维护成本
  • 集成到构建流程有一定工作量
  • 与部分第三方库的结合使用可能不是很完美(比如styled-components.)
// tsconfig.json
{
    "compilerOptions": {
        "target": "es5",
        "lib": [
            "dom",
            "dom.iterable",
            "esnext" 
        ],
        "allowJs": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
        "experimentalDecorators": true,
        "allowSyntheticDefaultImports": true,
        "strict": true,
        "forceConsistentCasingInFileNames": true,
        "module": "esnext",
        "moduleResolution": "node",
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "react",
        "downlevelIteration": true,
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        },
        "plugins": [
            {
                "transform": "typescript-plugin-styled-components",
                "type": "config"
            }
        ]
    },
    "include": [
        "src/**/*"
    ],
    "exclude": [
        "node_modules"
    ]
}

React Hook

Hook 是 React 16.8 的新增特性。它可以让你在不编写 class 的情况下使用 state 以及其它的 React 特性。

  • Hook 强化了 React 函数组件的能力,使得函数组件可以做到类组件中的 state 和生命周期。Hook 让类组件能实现的在函数组件中也都可以实现
  • 语法更加简洁,解决了高阶组件使用困难以及难以理解的问题
  • 向后兼容,类组件不会被舍弃

类组件与函数组件

类组件

import React, { Component } from 'react';

export default class Button extends Component {
  constructor() {
    super();
    this.state = { buttonText: 'Click' };
    this.handleClick = this.handleClick.bind(this);
  }
  handleClick() {
    this.setState(() => {
      return { buttonText: 'Click Done' };
    });
  }
  render() {
    const { buttonText } = this.state;
    return <button onClick={this.handleClick}>{buttonText}</button>;
  }
}

函数组件

import React, { useState } from 'react';

export default function Button() {
  const [buttonText, setButtonText] = useState('Click');
  function handleClick() {
    return setButtonText('Click Done');
  }
  return <button onClick={handleClick}>{buttonText}</button>;
}

类组件的缺点:

  • 需手动绑定this指向
  • 大型组件难以拆分和重构
  • 业务逻辑分散在各生命周期函数中,会导致逻辑重复(函数组件 useEffect 解决)
  • 引入了复杂编程模式,例如渲染属性(Render Props)、高阶组件(HOC)(函数组件自定义 Hook 解决)

Render Props

使用一个值为函数的prop来传递需要动态渲染的组件

import UIDemo from 'components/demo';
class DataProvider extends React.Component {
	constructor(props) {
		super(props);
		this.state = {target: 'Payen'};
	}
	render() {
		return (
			<div>
				{this.props.render(this.state)}
			</div>
		)
	}
}

<DataProvider render={data => (
	<UIDemo target={data.target} />
)}/>

<DataProvider>
	{data => (
		<UIDemo target={data.target}/>
	)}
</DataProvider>

HOC

函数接收一个组件作为参数,经过一系列加工,返回一个新组件

const withUser = WrappedComponent => {
	const user = sessionStorage.getItem('user');
	return props => <WrappedComponent user={user} {...props}/>;
}
const UserPage = props => (
	<div>
		<p>name: {props.user}</p>
	</div>
);

内置 Hook API

Hook API 名字以 use 开头


基础Hook:

  • useState
  • useEffect
  • useContext

附加Hook:

  • useReducer
  • useCallback
  • useMemo
  • useRef
  • useImperativeHandle
  • useLayoutEffect
  • useDebugValue

基础 Hook

useState

// 传入初始state值
const [state, setState] = useState(initState);

// 传入函数,函数返回值作为初始state,函数只会在初始渲染时被调用
const [state, setState] = useState(() => {
	const initState = Func();
	return initState;
});

useState 唯一参数是初始状态值,初始状态参数仅在第一次渲染时使用
返回当前的状态值(state)和用来变更状态的函数(setState)
类似于类组件中的this.setState
解构赋值语法允许我们将声明的状态赋予不同的名称

useEffect

useEffect(() => {
	Func(state);
	return () => {
		// 组件卸载时以及后续渲染重新运行效果之前执行
	};
}, [state]);

useEffect 接收一个包含命令式且可能有副作用代码的函数
React渲染阶段在函数组件主体内改变 DOM、设置定时器等包含副作用的操作是不允许的,因为会影响其它组件
useEffect 和类组件中的componentDidMountcomponentDidUpdatecomponentWillUnmount 有相同用途

import { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>
        Click me
      </button>
    </div>
  );
}

使用规则

  • 只能在顶层调用 Hooks。不能在循环,条件和嵌套函数中使用 Hook API
  • 仅在 React 函数组件中和自定义 Hooks 函数中使用 Hooks API

React 会根据调用 hook 的顺序依次将值存入数组
如果存在条件判断等可能会导致更新时不能获取对应的值,导致取值混乱

自定义Hook

为了与普通函数区分,自定义 Hook 命名以 use 开头
用来解决逻辑复用问题

// src/hooks/index.tsx
import React, { useState, useEffect, ... } from 'react';
...
// 元素可拖拽hook
function useDraggable(ref: React.RefObject<HTMLElement>) {
    const [{ dx, dy }, setOffset] = useState({ dx: 0, dy: 0 });

    useEffect(() => {
        if (ref.current == null) {
            throw new Error('[useDraggable] ref未注册到组件中');
        }
        const el = ref.current;

        const handleMouseDown = (event: MouseEvent) => {
            const startX = event.pageX - dx;
            const startY = event.pageY - dy;

            const handleMouseMove = (event: MouseEvent) => {
                const newDx = event.pageX - startX;
                const newDy = event.pageY - startY;
                setOffset({ dx: newDx, dy: newDy });
            };

            document.addEventListener('mousemove', handleMouseMove);
            document.addEventListener(
                'mouseup',
                () => {
                    document.removeEventListener('mousemove', handleMouseMove);
                },
                { once: true }
            );
        };

        el.addEventListener('mousedown', handleMouseDown);

        return () => {
            el.removeEventListener('mousedown', handleMouseDown);
        };
    }, [dx, dy, ref]);

    useEffect(() => {
        if (ref.current) {
            ref.current.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
        }
    }, [dx, dy, ref]);
}
// src/components/Usual/ButtonGroup.tsx
import { useDraggable, ... } from '@/hooks';
...
const ButtonGroup = (props: IButtonGroupProps) => {
	...
	// 预约弹窗可拖拽
	const el = useRef<HTMLDivElement>(null);
    useDraggable(el);
    return (
	    <Space gap="b" nowrap>
		    ...
		    <PopupWrapper ref={el}>
                <AppointPopup
                    show={showAppointBox}
                    switchAppointBox={switchAppointBox}
                    formid={formid}
                />
            </PopupWrapper>
		</Space>
    )
};

Hook 对比 HOC

import React from 'react';

function hocMatch(Component) {
  return class Match React.Component {
    componentDidMount() {
      this.getMatchInfo(this.props.matchId)
    }
    componentDidUpdate(prevProps) {
      if (prevProps.matchId !== this.props.matchId) {
        this.getMatchInfo(this.props.matchId)
      }
    }
    getMatchInfo = (matchId) => {
      // 请求后台接口获取赛事信息
    }
    render () {
      return (
        <Component {...this.props} />
      )
    }
  }
}

const MatchDiv=hocMatch(DivUIComponent)
const MatchSpan=hocMatch(SpanUIComponent)

<MatchDiv matchId={1} matchInfo={matchInfo} />
<MatchSpan matchId={1} matchInfo={matchInfo} />
function useMatch(matchId) {
  const [ matchInfo, setMatchInfo ] = useState('');
  useEffect(() => {
    // 请求后台接口获取赛事信息
    // ...
    setMatchInfo(serverResult) // serverResult后端返回数据
  }, [matchId]);
  return [matchInfo];
}
...
export default function Match({matchId}) {
  const [matchInfo] = useMatch(matchId);
  return <div>{matchInfo}</div>;
}

其它问题

地图跳转

const jumpMap = (e: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => {
    // coord_type坐标类型选择国测局坐标(火星坐标系)
    window.open(
        `http://api.map.baidu.com/marker?location=${pos?.lat},${pos?.lng}&title=${name ||
            '我的位置'}&content=${children}&output=html&coord_type=gcj02`
    );
    e.stopPropagation();
};

百度地图调起接口

二维码

import QRCode from 'qrcode.react';
...
<QRCode
    value={qrCodeUrl}
    size={300}
    fgColor="#000"
    imageSettings={{
        src: logo,
        height: 60,
        width: 60,
        excavate: false,
    }}
/>

屏幕适配

CSS 适配

直接使用 CSS 媒体查询对窄屏下单独设置样式

const PageContent = styled.div`
    width: 1200px;
    padding-top: 16px;
    margin: 0 auto;
    @media (max-width: 900px) {
        width: 740px;
    }
`;

JS 适配

实现一个自定义 Hook

// src/hooks/index.ts
...
function useNarrowScreen() {
    const isNarrow = () => window.innerWidth <= 900;
    const [narrow, setNarrow] = useState(isNarrow);
    useEffect(() => {
        const resizeHandler = () => setNarrow(isNarrow());
        window.addEventListener('resize', resizeHandler);
        return () => window.removeEventListener('resize', resizeHandler);
    });
    return narrow;
}
// src/components/Base/PhotoAlbum.tsx
...
import { useNarrowScreen } from '@/hooks';
...
const PhotoAlbum = (props: IPhotoAlbum) => {
	...
	const [baseLen, setBaseLen] = useState(0);
	const isNarrow = useNarrowScreen();
	useEffect(() => {
		setBaseLen(isNarrow ? 3 : 5);
		...
	}, [isNarrow, ...]);
};

日志相关

click日志

import React, { useEffect } from 'react';
import { sendLog } from '@/common/log';
...
useEffect(() => {
    const listenedEles = document.querySelectorAll('[data-mod]') || [];
    listenedEles.forEach((ele: HTMLElement) => {
        ele.onclick = function() {
            const mod = ele.getAttribute('data-mod');
            ...
        };
    });
}, []);

// src/hooks/useLog.ts
function useClickLog(ref: React.RefObject<HTMLElement>) {
    useEffect(() => {
        const el: any = ref.current;

        el.onclick = function() {
            sendLog(el);
        };
    }, [ref]);
}
const jumpMapRef = useRef(null);
useClickLog(jumpMapRef);
...
<MapHref
	ref={jumpMapRef}
	onClick={jumpMap}
	data-mod="map_click"
>
   查看
</MapHref>

show日志

页面进入埋点

在路由组件渲染回调函数中添加

// src/router/RouterWithSubRoutes.tsx
import React from 'react';
import { Route, Redirect, RouteComponentProps } from 'react-router-dom';
import { RouteInterface } from '@/types/route';
import { sendLog } from '@/common/log';
import { routers } from '@/common/router-config';

export const RouteWithSubRoutes = (
    route: RouteInterface,
    i: number,
    authed: boolean,
    authPath: string
) => {
    return (
        <Route
            key={i}
            path={route.path}
            exact={route.exact}
            render={(props: RouteComponentProps) => {
                const { match } = props;
                const location = props.location;
                if (routers[location.pathname]) {
                    sendLog({
                        mod: 'detail_show',
                        s_type: 'show',
                    });
                }
                if (!route.auth || authed || route.path === authPath) {
                    return <route.component {...props} routes={route.routes} />;
                }
                return <Redirect to={{ pathname: authPath, state: { from: props.location } }} />;
            }}
        />
    );
};


页面离开埋点

利用react-router 提供的离开确认组件Prompt

// src/App.tsx
import React from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { routes } from '@/router/router';
import { RenderRoutes } from '@/router/RenderRoutes';
import { GlobalStyle } from '@/style';
import { Prompt } from 'react-router';
import { sendLog } from '@/common/log';
import { routers } from '@/common/router-config';
...

const App: React.FC = () => {
	...
    return (
        <Router>
            <GlobalStyle />
            {RenderRoutes(routes, authed, authPath)}
            <Prompt
                message={location => {
                    // 退出页面前的逻辑
                    window.scrollTo(0, 0);

                    const { pathname, search } = window.location;
                    if (routers[pathname]) {
                        // sendLog
                    }
                    return true;
                }}
            />
        </Router>
    );
};

export default App;

页面状态缓存


Vue 中有keep-alive的组件功能,但是 React 官方没有提供支持
使用<Route>时,路由对应的组件在前进和后退无法被缓存,数据和行为会丢失

例如:列表页滚动到底后,点击跳转到详情页,返回后会回到列表顶部,数据以及滚动位置重置

<Route> 中配置的组件在路径不匹配时就会被卸载,对应真实节点也会从 DOM 树中移除

三种解决方案:

  • 手动实现类似 Vue 的 keep-alive 功能
  • 迁移其它第三方可以实现状态缓存的 Route 库
  • 页面状态存入 sessionStorage

通过实现自定义钩子 useStorage,可以将数据代理到其它数据源
LocalStorage / SessionStorage

// src/pages/search/index.tsx
...
import { useStorage } from '@/hooks';
...
const Search = () => {
	const location = useLocation();
    const params = new URLSearchParams(location.search);
    const tabSearch = params.get('tab') || 'shop';
	...
	// const [currentTab, changeCurrentTabState] = useState(tabSearch);
	const [currentTab, changeCurrentTabState] = useStorage('zlhx_home_tab', tabSearch);
	...
}
// src/hooks/index.ts
import React, { useState, useEffect, useCallback, Dispatch, SetStateAction } from 'react';
...
function useStorage<T>(
    key: string,
    defaultValue?: T | (() => T), // 默认值
    keepOnWindowClosed: boolean = false // 是否在窗口关闭后保持数据
): [T | undefined, Dispatch<SetStateAction<T>>, () => void] {
    const storage = keepOnWindowClosed ? localStorage : sessionStorage;

    // 尝试从Storage恢复值
    const getStorageValue = () => {
        try {
            const storageValue = storage.getItem(key);
            if (storageValue != null) {
                return JSON.parse(storageValue);
            } else if (defaultValue != null) {
                // 设置默认值
                const value =
                    typeof defaultValue === 'function' ? (defaultValue as () => T)() : defaultValue;
                storage.setItem(key, JSON.stringify(value));
                return value;
            }
        } catch (err) {
            console.warn(`useStorage 无法获取${key}: `, err);
        }

        return undefined;
    };

    const [value, setValue] = useState<T | undefined>(getStorageValue);

    // 更新组件状态并保存到Storage
    const save = useCallback<Dispatch<SetStateAction<T>>>(
        value => {
            setValue(prev => {
                const finalValue =
                    typeof value === 'function'
                        ? (value as (prev: T | undefined) => T)(prev)
                        : value;
                storage.setItem(key, JSON.stringify(finalValue));
                return finalValue;
            });
        },
        [storage, key]
    );

    // 移除状态
    const clear = useCallback(() => {
        storage.removeItem(key);
        setValue(undefined);
    }, [storage, key]);

    return [value, save, clear];
}
  • 1
    点赞
  • 1
    评论
  • 2
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

相关推荐
©️2020 CSDN 皮肤主题: 技术黑板 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值