【干货】被裁员前,我为公司做的15个前端基建分享~
共 33744字,需浏览 68分钟
·
2023-07-29 07:10
大厂技术 高级前端 精选文章
点击上方 全站前端精选,关注公众号
回复1,加入高级前段交流
半年时间撑过了三轮裁员,还是在第四轮的时候被裁了,差一周时间就入职满一年了。去年7月份换了一家新公司,刚进公司的时候感觉蒸蒸日上,特别有朝气,氛围也很轻松。这一年除了负责业务开发外,还做了很多前端基建方面的工作,多次技术分享,开发了很多个ai应用,对机器学习、ai这块产生了很多的兴趣。
工作期间也带了三个前端实习生,亦师亦友,结下了深厚的友谊。一个大专带了本科、985、研究生学历的实习生。不过不得不感叹学历太重要了,本科的实习生实习结束后工作难找,选择去考公。985和研究生的大厂随便面试,一个去了美团,一个去了华为,但技术能力是没有太多的差距的。
在里面也认识了很多关系好的朋友,最开始一切都很轻松,可是从去年年底开始就传出了要裁员的消息,把我关系最好的一个前端同事给裁了,今年来了后又陆陆续续裁了两波,部门内只剩三个前端了。
部门也换了新领导,上周一上午刚开会制定了新的开发流程,以为会稳定下来,短时间内不会再裁了,可是下午正在敲代码的时候新部门领导还是把我喊走了,说需求不多了,让我交接一下工作,补偿是n+1,有了前几轮裁员的经历,我也知道迟早会有这么一天,在公司虽然做了很多事情,但架不住在职时间短,被裁成本低,和新领导又不熟。
晚上回来改了改简历,后面几天投了一下,现在成都的环境很严峻,对大专更是不友好,虽然考了专升本,但感觉作用也不是特别大,现在好多外包都要求全日制本科。去年七月份找工作还能一天约两个面试,今年七月份找一周都很难约到两个。
不过还是要多调整简历和好好复习,努力去找工作,工作这五年再加上自己平时不断学习,在技术广度、深度还有前端架构能力都有着很丰富的经验,还有强大的学习能力和解决问题的能力,大环境改变不了,只能改变自己,多学习,确保每一个面试都能表现的很好。后面也会分享一下面试准备和面试遇到的一些问题。
在整理资料的时候发现了去年入职新公司一个月后结合自己以前所学和公司前端现状,总结一些可以更加规范和优化的点,在组内进行一次技术分享,现在再看也感慨颇多,当时的这些优化点,这一年时间现在也基本上都完成了,又整理了一下,来记录一下。
一. 项目目录规范
文件目录组织现在常用的有两种方式,后面公司采用的第二种,更方便一些。两种方式没有最好的,只有更适合自己公司的,只要公司内部达成一致了,用哪一种都会很方便。
1.1 按功能类型来划分
按文件的功能类型来分,比如api,组件,页面,路由,hooks,store,不管是全局使用到的,还是单独页面局部使用到的,都按照功能类型放在src下面对应的目录里面统一管理。
yaml
复制代码
├─src # 项目目录
│ ├─api # 数据请求
│ │ └─Home # 首页页面api
│ │ └─Kind # 分类页面api
│ ├─assets # 资源
│ │ ├─css # css资源
│ │ └─images # 图片资源
│ ├─config # 配置
│ ├─components # 组件
│ │ ├─common # 公共组件
│ │ └─Home # 首页页面组件
│ │ └─Kind # 分类页面组件
│ ├─layout # 布局
│ ├─hooks # 自定义hooks组件
│ ├─routes # 路由
│ ├─store # 状态管理
│ │ └─Home # 首页页面公共的状态
│ │ └─Kind # 分类页面公共的状态
│ ├─pages # 页面
│ │ └─Home # 首页页面
│ │ └─Kind # 分类页面
│ ├─utils # 工具
│ └─main.ts # 入口文件
1.2 按领域模型划分
按照页面功能划分,全局会用到的组件,api等还是放到src下面全局管理,页面内部单独使用的api和组件放到对应页面的文件夹里面,使用的时候不用上下查找文件,在当前页面文件夹下就能找到,比较方便,功能也内聚一些。
yaml
复制代码
├─src # 项目目录
│ ├─assets # 资源
│ │ ├─css # css资源
│ │ └─images # 图片资源
│ ├─config # 配置
│ ├─components # 公共组件
│ ├─layout # 布局
│ ├─hooks # 自定义hooks组件
│ ├─routes # 路由
│ ├─store # 全局状态管理
│ ├─pages # 页面
│ │ └─Home # 首页页面
│ │ └─components # Home页面组件文件夹
│ │ ├─api # Home页面api文件夹
│ │ ├─store # Home页面状态
│ │ ├─index.tsx # Home页面
│ │ └─Kind # 分类页面
│ ├─utils # 工具
│ └─main.ts # 入口文件
二. 代码书写规范
规范比较多,这里只简单列举一下基本的规范约束和使用工具来自动化规范代码。
2.1 组件结构
react组件
tsx
复制代码
import React, { memo, useMemo } from 'react'
interface ITitleProps {
title: string
}
const Title: React.FC<ITitleProps> = props => {
const { title } = props
return (
<h2>{title}</h2>
)
}
export default memo(Title)
ITitleProps 以I为开头代表类型,中间为语义化Title,后面Props为类型,代表是组件参数。
2.2 定义接口
例1: 登录接口,定义好参数类型和响应数据类型,参数类型直接定义params的类型,响应数据放在范型里面,需要在封装的时候就处理好这个范型。
tsx
复制代码
import { request } from '@/utils/request'
/** 公共的接口响应范型 */
export interface HttpSuccessResponse<T> {
code: number
message: string
data: T
}
/** 登录接口参数 */
export interface ILoginParams {
username: string
password: string
}
/** 登录接口响应 */
export interface ILoginData {
token: string
}
/* 用户登录接口 */
export const loginApi = (params: ILoginApi) => {
return request.post<ILoginData>('/xxx', params)
}
2.3 事件
以on开头代表事件,这个只是规范,on要比handle短一点,哈哈。
tsx
复制代码
const onChange = () => {
}
2.4 工具约束代码规范
除了约定俗称的规范,我们也需要借助一些工具和插件来协助我们更好的完成规范这件事情。
代码规范
-
vscode[1]:统一前端编辑器。 -
editorconfig[2]: 统一团队vscode编辑器默认配置。 -
prettier[3]: 保存文件自动格式化代码。 -
eslint[4]: 检测代码语法规范和错误。 -
stylelint[5]: 检测和格式化样式文件语法
可以看我这篇文章:【前端工程化】配置React+ts企业级代码规范及样式格式和git提交规范[6]
git提交规范
-
husky[7]:可以监听githooks[8]执行,在对应hook执行阶段做一些处理的操作。 -
lint-staged[9]: 只检测暂存区文件代码,优化eslint检测速度。 -
pre-commit[10]:githooks之一, 在commit提交前使用tsc和eslint对语法进行检测。 -
commit-msg[11]:githooks之一,在commit提交前对commit备注信息进行检测。 -
commitlint[12]:在githooks的pre-commit阶段对commit备注信息进行检测。 -
commitizen[13]:git的规范化提交工具,辅助填写commit信息。
可以看我这篇文章:【前端工程化】配置React+ts企业级代码规范及样式格式和git提交规范[14]
三. 状态管理器优化和统一
3.1 优化状态管理
用react的context封装了一个简单的状态管理器,有完整的类型提升,支持在组件内和外部使用,也发布到npm[15]了
tsx
复制代码
import React, { createContext, useContext, ComponentType, ComponentProps } from 'react'
/** 创建context组合useState状态Store */
function createStore<T>(store: () => T) {
// eslint-disable-next-line
const ModelContext: any = {};
/** 使用model */
function useModel<K extends keyof T>(key: K) {
return useContext(ModelContext[key]) as T[K];
}
/** 当前的状态 */
let currentStore: T;
/** 上一次的状态 */
let prevStore: T;
/** 创建状态注入组件 */
function StoreProvider(props: { children: React.ReactNode }) {
currentStore = store();
/** 如果有上次的context状态,做一下浅对比,
* 如果状态没变,就复用上一次context的value指针,避免context重新渲染
*/
if (prevStore) {
for (const key in prevStore) {
// @ts-ignore
if (shallow(prevStore[key], currentStore[key])) {
// @ts-ignore
currentStore[key] = prevStore[key];
}
}
}
prevStore = currentStore;
// @ts-ignore
let keys: any[] = Object.keys(currentStore);
let i = 0;
const length = keys.length;
/** 遍历状态,递归形成多层级嵌套Context */
function getContext<T, K extends keyof T>(
key: K,
val: T,
children: React.ReactNode,
): JSX.Element {
const Context =
ModelContext[key] || (ModelContext[key] = createContext(val[key]));
const currentIndex = ++i;
/** 返回嵌套的Context */
return React.createElement(
Context.Provider,
{
value: val[key],
},
currentIndex < length
? getContext(keys[currentIndex], val, children)
: children,
);
}
return getContext(keys[i], currentStore, props.children);
}
/** 获取当前状态, 方便在组件外部使用,也不会引起页面更新 */
function getModel<K extends keyof T>(key: K): T[K] {
return currentStore[key];
}
/** 连接Model注入到组件中 */
function connectModel<Selected, K extends keyof T>(
key: K,
selector: (state: T[K]) => Selected,
) {
// eslint-disable-next-line func-names
// @ts-ignore
return function <P, C extends ComponentType<any>>(
WarpComponent: C,
): ComponentType<Omit<ComponentProps<C>, keyof Selected>> {
const Connect = (props: P) => {
const val = useModel(key);
const state = selector(val);
// @ts-ignore
return React.createElement(WarpComponent, {
...props,
...state,
});
};
return Connect as unknown as ComponentType<
Omit<ComponentProps<C>, keyof Selected>
>;
};
}
return {
useModel,
connectModel,
StoreProvider,
getModel,
};
}
export default createStore
/** 浅对比对象 */
function Shallow<T>(obj1: T, obj2: T) {
if(obj1 === obj2) return true
if(Object.keys(obj1).length !== Object.keys(obj2).length) return false
for(let key in obj1) {
if(obj1[key] !== obj2[key]) return false
}
return true
}
3.2 store目录结构
yaml
复制代码
├─src # 项目目录
│ ├─store # 全局状态管理
│ │ └─modules # 状态modules
│ │ └─user.ts # 用户信息状态
│ │ ├─other.ts # 其他全局状态
│ │ ├─createStore.ts # 封装的状态管理器
│ │ └─index.ts # store入口页面
3.3 定义状态管理器
1. 在store/index.ts中引入
tsx
复制代码
import { useState } from 'react'
/** 1. 引入createStore.ts */
import createStore from './createStore'
/** 2. 定义各个状态 */
// user
const userModel = () => {
const [ userInfo, setUserInfo ] = useState<{ name: string }>({ name: 'name' })
return { userInfo, setUserInfo }
}
// other
const otherModel = () => {
const [ other, setOther ] = useState<number>(20)
return { other, setOther }
}
/** 3. 组合所有状态 */
const store = createStore(() => ({
user: userModel(),
other: otherModel(),
}))
/** 向外暴露useModel, StoreProvider, getModel, connectModel */
export const { useModel, StoreProvider, getModel, connectModel } = store
2. 在顶层通过StoreProvider注入状态
tsx
复制代码
// src/main.ts
import React from 'react'
import ReactDOM from 'react-dom'
import App from '@/App'
// 1. 引入StoreProvider
import { StoreProvider } from '@/store'
// 2. 使用StoreProvider包裹App组件
ReactDOM.render(
<StoreProvider>
<App />
</StoreProvider>,
document.getElementById('root')
)
3.4 使用状态管理器
1. 在函数组件中使用,借助useModel
tsx
复制代码
import React from 'react'
import { useModel } from '@/store'
function FunctionDemo() {
/** 通过useModel取出user状态 */
const { userInfo, setUserInfo } = useModel('user')
/** 在点击事件中调用setUserInfo改变状态 */
const onChangeUser = () => {
setUserInfo({
name: userInfo.name + '1',
})
}
// 展示userInfo.name
return (
<button onClick={onChangeUser}>{userInfo.name}--改变user中的状态</button>
)
}
export default FunctionDemo
2. 在class组件中使用,借助connectModel
tsx
复制代码
import React, { Component } from 'react'
import { connectModel } from '@/store'
// 定义class组件props
interface IClassDemoProps {
setOther: React.Dispatch<React.SetStateAction<string>>
other: number
}
class ClassDemo extends Component<IClassDemoProps> {
// 通过this.props获取到方法修改状态
onChange = () => {
this.props.setOther(this.props.other + 1)
}
render() {
// 通过this.props获取到状态进行展示
return <button onClick={this.onChange}>{this.props.other}</button>
}
}
// 通过高阶组件connectModel把other状态中的属性和方法注入到类组件中
export default connectModel('other',state => ({
other: state.other,
setOther: state.setOther
}))(ClassDemo)
3. 在组件外使用, 借助getModel
也可以在组件内读取修改状态方法,不回引起更新
tsx
复制代码
import { getModel } from '@/store'
export const onChangeUser = () => {
// 通过getModel读取usel状态,进行操作
const user = getModel('user')
user.setUserInfo({
name: user.userInfo.name + '1'
})
}
// 1秒后执行onChangeUser方法
setTimeout(onChangeUser, 1000)
四. 本地存储统一管理
可以对localStorage和sessionStorage还有cookie简单封装一下,封装后使用的好处:
-
自动序列化,存储的时候转字符串,取得时候再转回来。 -
类型自动推断,在实例化的时候传入类型,在设置和获取值的时候都会自动类型推断。 -
可以统一管理,把本地存储都放在一个文件里面,避免后期本地存储混乱不好维护问题。 -
抹平平台差异,这个思路web,小程序,移动端,桌面端都适合。
tsx
复制代码
// src/utils/storage.ts
const prefix = 'xxx.'
interface IStorage<T> {
key: string
defaultValue: T
}
export class LocalStorage<T> implements IStorage<T> {
key: string
defaultValue: T
constructor(key, defaultValue) {
this.key = prefix + key
this.defaultValue = defaultValue
}
/** 设置值 */
setItem(value: T) {
localStorage.setItem(this.key, JSON.stringify(value))
}
/** 获取值 */
getItem(): T {
const value = localStorage[this.key] && localStorage.getItem(this.key)
if (value === undefined) return this.defaultValue
try {
return value && value !== 'null' && value !== 'undefined'
? (JSON.parse(value) as T)
: this.defaultValue
} catch (error) {
return value && value !== 'null' && value !== 'undefined'
? (value as unknown as T)
: this.defaultValue
}
}
/** 删除值 */
removeItem() {
localStorage.removeItem(this.key)
}
}
实例化封装的本地存储
tsx
复制代码
// src/common/storage.ts
import { LocalStorage } from '@/utils/storage'
/** 管理token */
export const tokenStorage = new LocalStorage<string>('token', '')
/** 用户信息类型 */
export interface IUser {
name?: string
age?: num
}
/** 管理用户信息 */
export const userStorage = new Storage<IUser>('user', {})
页面内使用
tsx
复制代码
import React, { memo, useMemo } from 'react'
import { userStorage } from '@/common/storage'
interface ITitleProps {
title: string
}
const Title: React.FC<ITitleProps> = props => {
const { title } = props
useEffect(() => {
userStorage.setItem({ name: '姓名', age: 18 })
const user = userStorage.getItem()
console.log(user) // { name: '姓名', age: 18 }
}, [])
return (
<h2>{title}</h2>
)
}
export default memo(Title)
五. 封装请求统一和项目解耦
5.1 现有的封装
项目现用的请求封装和项目业务逻辑耦合在一块,不方便直接复用,使用上比较麻烦,每次需要传GET和POST类型,GET参数要每次单独做处理,参数类型限制弱。
5.2 推荐使用
推荐直接使用fetch封装或axios,项目中基于次做二次封装,只关注和项目有关的逻辑,不关注请求的实现逻辑。在请求异常的时候不返回Promise.reject() ,而是返回一个对象,只是code改为异常状态的code,这样在页面中使用时,不用用try/catch包裹,只用if判断code是否正确就可以。
tsx
复制代码
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
import { tokenStorage } from '@/common/storage'
/** 封装axios的实例,方便多个url时的封装 */
export const createAxiosIntance = (baseURL: string): AxiosInstance => {
const request = axios.create({ baseURL })
// 请求拦截器器
request.interceptors.request.use((config: AxiosRequestConfig) => {
config.headers['Authorization'] = tokenStorage.getItem()
return config
})
// 响应拦截器
request.interceptors.response.use(
response => {
const code = response.data.code
switch (code) {
case 0:
return response.data
case 401:
// 登录失效逻辑
return response.data || {}
default:
return response.data || {}
}
},
error => {
// 接口请求报错时,也返回对象,这样使用async/await就不需要加try/catch
// code为0为请求正常,不为0为请求异常,使用message提示
return { message: onErrorReason(error.message) }
}
)
return request
}
/** 解析http层面请求异常原因 */
function onErrorReason(message: string): string {
if (message.includes('Network Error')) {
return '网络异常,请检查网络情况!'
}
if (message.includes('timeout')) {
return '请求超时,请重试!'
}
return '服务异常,请重试!'
}
export const request = createAxiosIntance('https://xxx')
5.3 使用
使用上面代码命名定义接口类型的loginApi例子
tsx
复制代码
/** 登录 */
const onLogin = async () => {
const res = await loginApi(params)
if(res.code === 0) {
// 处理登录正常逻辑
} else {
message.error(res.message) // 错误提示也可以在封装时统一添加
}
}
六. api接口管理统一
文件夹路径
yaml
复制代码
├─pages # 页面
│ ├─Login # 登录页面
│ │ └─api # api文件夹
│ │ └─index.ts # api函数封装
│ │ ├─types.ts # api的参数和响应类型
定义类型
tsx
复制代码
// api/types.ts
/** 登录接口参数 */
export interface ILoginParams {
username: string
password: string
}
/** 登录接口响应 */
export interface ILoginData {
token: string
}
定义请求接口
tsx
复制代码
import { request } from '@/utils/request'
import { ILoginParams, ILoginData } from './types'
/* 用户登录接口 */
export const loginApi = (params: ILoginParams) => {
return request.post<ILoginData>('/distribute/school/login', params)
}
使用请求接口
使用上面代码命名定义接口类型的loginApi例子
tsx
复制代码
/** 登录 */
const onLogin = async () => {
const res = await loginApi(params)
if(res.code === 0) {
// 处理登录正常逻辑
} else {
message.error(res.message) // 错误提示也可以在封装时统一添加
}
}
七. 函数库-通用方法抽离复用
把公司项目中常用的方法和hooks抽离出来组成函数库,方便在各个项目中使用,通过编写函数方法,写jest单元测试,也可以提升组内成员的整体水平。当时组内前端不管是实习生还是正式成员都在参与函数库的建设,很多就有了 30+ 的函数和hooks,还在不断的增加。
是用了dumi2来开发的函数库,可以看我的这篇文章【前端工程化】使用dumi2搭建React组件库和函数库详细教程[16]
八. 组件库-通用组件抽离复用
公司项目多了会有很多公共的组件,可以抽离出来,方便其他项目复用,一般可以分为以下几种组件:
-
UI组件 -
业务组件 -
功能组件:上拉刷新,滚动到底部加载更多,虚拟滚动,拖拽排序,图片懒加载..
由于公司技术栈主要是react,组件库也是采用了dumi2的方案,可以看我的这篇文章【前端工程化】使用dumi2搭建React组件库和函数库详细教程[17]
九. css超集和css模块化方案统一
css超集
使用less或者scss,看项目具体情况,能全项目统一就统一。
css模块化
vue使用自带的style scoped, react使用css-module方案。
开启也简单,以vite为例,默认支持,可以修改vite.config.ts配置:
tsx
复制代码
// vite.config.ts
export default defineConfig({
css: {
// 配置 css-module
modules: {
// 开启 camelCase 格式变量名转换
localsConvention: 'camelCase',
// 类名格式,[local]是自己原本的类名,[hash:base64:5]是5位的hash值
generateScopedName: '[local]-[hash:base64:5]',
}
},
})
使用的时候,样式文件命名后缀需要加上 .module,例如index.module.less
:
less
复制代码
// index.module.less
.title {
font-size: 18px;
color: yellow;
}
组件里面使用:
tsx
复制代码
import React, { memo, useMemo } from 'react'
import styles from './index.module.less'
interface ITitleProps {
title: string
}
const Title: React.FC<ITitleProps> = props => {
const { title } = props
return (
<h2 className={styles.title}>{title}</h2>
)
}
export default memo(Title)
编译后类名会变成title-[hash:5] ,可以有效避免样式冲突,减少起类名的痛苦。
十. 引入immer来优化性能和简化写法
Immer[18] 是 mobx 的作者写的一个 immutable 库,核心实现是利用 ES6 的 Proxy(不支持Proxy的环境会自动使用Object.defineProperty来实现),几乎以最小的成本实现了 js 的不可变数据结构,简单易用、体量小巧、设计巧妙,满足了我们对js不可变数据结构的需求。
1. 优化性能
修改用户信息
tsx
复制代码
const [ userInfo, setUserInfo ] = useState({ name: 'immer', info: { age: 6 } })
const onChange = (age: number) => {
setUserInfo({...userInfo, info: {
...userinfo.info,
age: age
}})
}
上面某次修改age没有变,但setUserInfo时每次都生成了一个新对象,更新前后引用变化了,组件就会刷新。
使用immer后,age没变时不会生成新的引用,同时语法也更简洁,可以优化性能。
tsx
复制代码
import produce from 'immer'
const [ userInfo, setUserInfo ] = useState({ name: 'immer', age: 5 })
const onChange = (age: number) => {
setUserInfo(darft => {
darft.age = age
})
}
2.简化写法
react遵循不可变数据流的理念,每次修改状态都要新生成一个引用,不能在原先的引用上进行修改,所以在对引用类型对象或者数组做操作时,总要浅拷贝一下,再来做处理,当修改的状态层级比较深的时候,写法会更复杂。
以数组为例,修改购物车某个商品的数量:
tsx
复制代码
import produce from 'immer'
const [ list, setList ] = useState([{ price: 100, num: 1 }, { price: 200, num: 1 }])
// 不使用用immer
const onAdd = (index: number) => {
/** 不使用immer */
// const item = { ...list[index] }
// item.num++
// list[index] = item
// setList([...list])
/** 使用immer */
setList(
produce(darft => {
darft[index].num++
}),
)
}
3. 可以用use-immer[19]简化写法:
tsx
复制代码
import useImmer from 'use-immer'
const [ list, setList ] = useImmer([{ price: 100, num: 1 }, { price: 200, num: 1 }])
const onAdd = (index: number) => {
setList(darft => {
darft[index].num++
})
}
十一. 搭建npm私服
公司前端项目不推荐使用太多第三方包,可以自己搭建公司npm私服,来托管公司自己封装的状态管理库,请求库,组件库,以及脚手架cli,sdk等npm包,方便复用和管理。
可以看我这两篇文章,都可以搭建npm私服:
【前端工程化】巧用阿里云oss服务打造前端npm私有仓库[20]
【前端工程化】使用verdaccio搭建公司npm私有库完整流程和踩坑记录[21]
十二. 各类型项目通用模版封装
可以提前根据公司的业务需求,封装出各个端对应通用开发模版,封装好项目目录结构,接口请求,状态管理,代码规范,git规范钩子,页面适配,权限,本地存储管理等等,来减少开发新项目时前期准备工作时间,也能更好的统一公司整体的代码规范。
-
通用后台管理系统基础模版封装 -
通用小程序基础模版封装 -
通用h5端基础模版封装 -
通用node端基础模版封装 -
其他类型的项目默认模版封装,减少重复工作。
十三. 搭建cli脚手架下载模版。
搭建类似vue-cli, vite, create-react-app类的cli命令行脚手架来快速选择和下载封装好的模版,比git拉代码要方便。
具体cli脚手架的实现可以看我这篇文章:【前端工程化】从入门到精通,100行代码构建你的前端CLI脚手架之路[22]
十四. git操作规范
git操作规范也很重要,流程不规范很容易出现比较复杂的问题,要根据公司现有情况和业界比较好的实践方案制定一套适合自己公司的git flow开发规范,用各种限制方案来避免出现问题,这个具体流规范后面会总结一篇文章出来。
十五. 规范和使用文档输出文档站点
代码规范和git提交规范以及各个封装的库使用说明要输出成文档部署到线上,方便新同事快速熟悉和使用。
这个是很重要的,做了再多的基建和规范,如果没有一个公共的文文档来查阅,就没办法快速熟悉,所以要一个线上的规范文档,把所有的规范都写进去,可以用语雀。
作者:Ausra无忧
链接:https://juejin.cn/post/7256393626682163237
来源:稀土掘金
下方加 Nealyang 好友回复「 加群」即可。
如果你觉得这篇内容对你有帮助,我想请你帮我2个小忙: