前言
众所周知,hooks在 [email protected] 中已经正式发布了。而下周周会,我们团队有个同学将会仔细介绍分享一下hooks。最近网上呢有不少hooks的文章,这不免激起了我自己的好奇心,想先行探探hooks到底好不好用。
react hooks在其文档的最开头,就阐明了hooks的一个鲜明作用跟几个动机(或者说hooks的好处)。
明确的作用
它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。
意思很明了,就是拓展函数式组件的边界。结果也很清晰,只要Class 组件能实现的,函数式组件+Hooks都能胜任。
动机
文档中列了三点:
- 在组件之间复用状态逻辑很难;
- 复杂组件变得难以理解;
- class让开发人员与计算机都难理解;
关于这三点的详细介绍文档里也有,还有中文的,我就不多说了。
我对动机的理解
动机也即是hooks能带来的好处。其中第三点,文档中所说class的弊端对于我本人,还是有点儿不痛不痒。this的问题,箭头函数解决的差不多了;语法提案也到stage-3了;代码压缩什么的,自己的资源代码大小往往不是核心问题。
现在说利用hooks可以胜任class组件所有的能力。但你胜任归你胜任,我写class又有什么不可以。我继承、高阶骚的一逼,要啥hooks。
然而第1、2两点还是吸引了我的注意。状态逻辑的复用,之前我主要采用高阶组件+继承,虽然也能解决,但hooks似乎有更优雅的方案。复杂组件变得难以理解,这个也确实是平常中遇到的问题,一个组件写着写着状态越来越多,抽成子组件吧props跟state又传来传去。三个月后,自己的代码自己已经看不懂了。
那hooks真的就能更好的解决这些问题么?文档里轻飘飘的几句话,对于实际业务来说,确实没有太多体感。于是我决定简单写几个场景,探一探这hooks的活到底好不好。
状态逻辑的复用
这种场景其实挺常见。只要页面中有需要复用的组件,且这个组件又有较为复杂的状态逻辑,就会有这样的需求。举个例子:中后台系统常见的各种列表,表格内容各不相同,但是都要有分页的行为,于是分页组件就需要去抽象。按照正常的写法,我们会怎么做呢?
传统流派
最开始,我们可能不会想着通用,就写一个列表+分页的组件。以最简单的分页为例,可能会如下写(为方便阅读,不做太多异常处理):
import { Component } from 'react';
import { range } from 'lodash';
// 模拟列表数据请求
const fetchList = ({ page = 1, size = 10 }) =>
new Promise(resolve => resolve(range((page - 1) * size, page * size)))
export default class ListWithPagination extends Component {
state = {
page: 1,
data: [],
}
componentDidMount() {
this.fetchListData(this.setState);
}
handlePageChange = newPage =>
this.setState({ page: newPage }, this.fetchListData)
fetchListData = () => {
const { page } = this.state;
fetchList({ page }).then(data => this.setState({ data }));
}
render() {
const { data, page } = this.state;
return (
<div>
<ul className="list">
{data.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
<div className="nav">
<button type="button" onClick={() => this.handlePageChange(page - 1)}>
上一页
</button>
<label>当前页: {page}</label>
<button type="button" onClick={() => this.handlePageChange(page + 1)}>
下一页
</button>
</div>
</div>
);
}
}
复制代码
然后我们就会想,每个地方都要有分页,唯一不太一样的仅是 列表渲染 跟数据请求api而已,那何不抽个高阶组件呢?于是代码变成了:
export default function ListHoc(ListComponent) {
return class ListWithPagination extends Component {
// ...同上述code,省略
// 数据请求方法,从props中传入
fetchListData = () => {
const { fetchApi } = this.props;
const { page } = this.state
return fetchApi({ page }).then(data => this.setState({ data }));
}
render() {
const { data, page } = this.state;
return (
<div>
<ListComponent data={data} />
<div className="nav">...省略</div>
</div>
);
}
};
}
复制代码
这么一来,未来再写列表时,使用高阶组件包裹一下,再把数据请求方法 以props传入,就能达到一个复用状态逻辑与分页组件的效果了。
就在我们得意之际,又来了一个新需求,说有一个列表的分页导航,需要在 列表上面,而不是 列表下面,换成程序语言意思就是Dom的结构与样式有变更。唔…..仔细想想有几种方案:
- 传递一个props叫“theme”,控制不同的顺序跟样式….乍一看还行,但如果未来两种列表风格越来越远,这个高阶组件会越来越重….不行不行。
- 再写一个类似的高阶组件,dom结构不一样,但其他一模一样。唔,代码重复度这么高,真low,不行不行。
- 再写一个组件,继承这个这个高阶组件,重写render。好像还可以,就是这个继承关系略略有点儿奇怪,应该是兄弟关系,而不是继承关系。当然我可以再抽象一层包含状态逻辑处理的通用Component,两种列表形式的高阶组件都是继承它,而不是继承 React.Component。但是即使如此,通过继承来复写render的方式,无法清晰感知组件到底有哪些状态值,尤其在状态较多,逻辑较为复杂的情况下。这样日后维护,或者拓展render时,就举步维艰。
这也不行,那也不好。那用hooks来做又能做成哪样呢?
Hooks流派
注:为了简化,下文中的 effect 都指代 side effect。
尝试改造
首先,我们把最开始那个 ListWithPagination
以hooks改写,那就成了:
import { useState, useEffect } from 'react';
import { range } from 'lodash';
// 模拟列表数据请求
const fetchList = ({ page = 1, size = 10 }) =>
new Promise(resolve => resolve(range((page - 1) * size, page * size)));
export default function List() {
const [page, setPage] = useState(1); // 初始页码为: 1
const [list, setList] = useState([]); // 初始列表数据为空数组: []
useEffect(() => {
fetchList({ page }).then(setList);
}, [page]); // 当page变更时,触发effect
const prevPage = () => setPage(currentPage => currentPage - 1);
const nextPage = () => setPage(currentPage => currentPage + 1);
return (
<div>
<ul>
{list.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
<div>
<button type="button" onClick={prevPage}>
上一页
</button>
<label>当前页: {page}</label>
<button type="button" onClick={nextPage}>
下一页
</button>
</div>
</div>
);
}
复制代码
为防止部分同学不理解,我再简单介绍下 useState 与 useEffect。
- useState: 执行后,返回一个数组,第一个值为状态值,第二个值为更新此状态值的对应方法。useState函数入参为state初始值。
- useEffect:执行副作用操作。第一个参数为副作用方法,第二个参数是一个数组,填写副作用依赖项。当依赖项变了时,副作用方法才会执行。若为空数组,则只执行一次。如不填写,则每次render都会触发。
如果对此还是不理解,建议先看下相关文档。如果关于副作用不理解,可以到文章最后再看。在我们当下的场景中,知道异步请求数据并更新组件内部状态值就属于副作用的一种即可。
知道基本概念以后,我们看上述的代码,其实也大致能理解其机制。
- 组件初始化也即第一次render后,会触发一次effect,请求第一页数据后,更新列表数据
list
,进而又触发第二次render。 - 在第二次render中,useState会获取当前的
list
值,而不是初始值,进而页面渲染新的列表。至于react如何做到能数据的匹配,文档里有简单介绍。 - 在后续的用户点击行为中,触发了setPage,进而更新了
page
,由于它的变更触发了effect,effect执行后又更新list
,触发新的render,渲染最新的列表。
在了解机制以后,我们就要开始做正经事了。上述传统流派中,通过高阶组件抽象公共逻辑。现在我们通过hooks改造了最初的class组件。下一步应该抽离状态逻辑。类似刚刚高阶组件的结果,我们期望将分页的行为抽离,那太简单了,把处理状态的相关代码封装成函数,抽离出组件,再传递一下数据请求api就好:
// 传递获取数据api,返回 [当前列表,分页数据,分页行为]
const usePagination = (fetchApi) => {
const [page, setPage] = useState(1); // 初始页码为: 1
const [list, setList] = useState([]); // 初始列表数据为空数组: []
useEffect(() => {
fetchApi({ page }).then(setList);
}, [page]); // 当page变更时,触发effect
const prevPage = () => setPage(currentPage => currentPage - 1);
const nextPage = () => setPage(currentPage => currentPage + 1);
return [list, { page }, { prevPage, nextPage }];
};
export default function List() {
const [list, { page }, { prevPage, nextPage }] = usePagination(fetchList);
return (
<div>...省略</div>
);
}
复制代码
如果你希望分页的dom结构也想复用,那就再抽个函数便好。
function renderCommonList({ ListComponent, fetchApi }) {
const [list, { page }, { prevPage, nextPage }] = usePagination(fetchApi);
return (
<div>
<ListComponent list={list} />
<div>
<button type="button" onClick={prevPage}>
上一页
</button>
<label>当前页: {page}</label>
<button type="button" onClick={nextPage}>
下一页
</button>
</div>
</div>
);
}
export default function List() {
function ListComponent({ list }) {
return (
<ul>
{list.map((item, key) => (
<li key={key}>{item}</li>
))}
</ul>
);
}
return renderCommonList({
ListComponent,
fetchApi: fetchList,
});
}
复制代码
如果你希望有一个新的分页结构与样式,那就重写一个结构,并引用 usePagination
。总之,最核心的状态处理逻辑已经被我们抽离出来,因为无关this,于是它与组件无关、与dom也可以无关。爱插哪插哪,谁爱用谁用。百花丛中过,片叶不沾身。
这么一来,数据层与dom更加的分离,react组件更加的退化成一层UI层,进而更易阅读、维护、拓展。
场景深入
不过不能开心的太早。做事如果浅尝则止,往往后续会遇到深坑。就以刚刚的需求来说,有些特殊逻辑还未考察到。假如说,我们的分页请求会失败,而页码已经更新,这该怎么办?一般来说有几个思路:
- 请求失败以后回滚页码。但实现不优雅,且页码跳来跳去,放弃。
- 数据请求成功以后再更新页码。比较适合移动端滚动加载的情况。
- 不回滚页码,列表页提示异常,点击触发重试。比较适合上述中分页列表的情况。
那我们就按方案3,暴露一个error的状态,提供一个刷新页面的方法。我们突然意识到一个问题,如何刷新页面数据呢?我们的effect依赖于page变更,而刷新页面不变更page,effect便不会触发。想一下,也有两个思路:
- 再加一个关于刷新的状态值,刷新页面数据的方法,每次执行都会为其+1,触发effect。不过这样会导致组件平白无故加个状态值。
- 依赖项改为一个对象,page为对象中一个属性,日后也方便拓展。由于对象无法对比的特性,每次setState都会触发effect。不过有可能会导致数据无意义的重复获取,比如快速点击同一个页码时,触发了两次数据获取。
综合考虑来说,我采取第二个方案。因为effect强依赖于入参的变更也不合理,毕竟这是一个有副作用的方法。相同的分页入参下,服务端也有可能返回不同的结果。数据重复获取的问题,可以手动加入防抖等手段优化。具体代码如下:
const usePagination = (fetchApi) => {
const [query, setQuery] = useState({ page: 1, size: 15 }); // 初始页码为: 1
const [isError, setIsError] = useState(false); // 初始状态为false
const [list, setList] = useState([]); // 初始列表数据为空数组: []
useEffect(() => {
setIsError(false);
fetchApi(query)
.then(setList)
.catch(() => setIsError(true));
}, [query]); // 当页面查询参数变更时,触发effect
const { page, size } = query;
const prevPage = () => setQuery({ size, page: page - 1 });
const nextPage = () => setQuery({ size, page: page + 1 });
const refreshPage = () => setQuery({ ...query });
// 如果数据过多,数组解构麻烦,也可以选择返回对象
return [list, query, { prevPage, nextPage, refreshPage }, isError];
};
复制代码
但是如果按照方案2呢?「数据请求成功以后再更新页码」。在移动端的长列表滚动加载时,页面并不透出页码,滚动加载失败时,toast提示失败,再滚动依旧加载刚刚失败的那一页。然而在我们的 usePagination
中,数据请求的effect必须是通过query变更来触发的,无法实现请求结束以后再更改页码。如果是通过方案1「请求失败以后回滚页码」,那由于回滚了页面,又会触发一次effect请求,这也不是我们想看到的。
其实这是钻了牛角尖,这本身已经是不同的场景了。在移动端的滚动加载中,是否加载并非是由“页码变更”控制,而是由“是否滚动到底部”控制。于是代码应该是:
// 滚动到底部时,执行
const useBottom = (action, dependencies) => {
function doInBottom() {
// isScrollToBottom 的代码不贴了
return isScrollToBottom() && action();
}
useEffect(() => {
window.addEventListener('scroll', doInBottom);
// 组件卸载或函数下一次执行时,会先执行上一次函数内部return的方法
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
// 因为每次是请求下一页数据,所以现在初始页码为: 0
const [query, setQuery] = useState({ page: 0, size: 50 });
const [list, setList] = useState([]); // 初始列表数据为空数组: []
const fetchAndSetQuery = () => {
// 每次请求下一页数据
const newQuery = {
...query,
page: query.page + 1,
};
fetchApi(newQuery)
.then((newList) => {
// 成功后插入新列表数据,并更新最新分页参数
setList([...list, ...newList]);
setQuery(newQuery);
})
.catch(() => window.console.log('加载失败,请重试'));
};
// 首次mount后触发数据请求
useEffect(fetchAndSetQuery, []);
// 滚动到底部触发数据请求
useBottom(fetchAndSetQuery);
return [list];
};
复制代码
其中在 useBottom
内的effect函数中,返回了一个解绑滚动事件的函数,在组件卸载或者下一次effect触发时,会先执行此函数进行解绑行为。在传统的class组件中,我们一般是在unmount阶段去解绑事件。如果副作用依赖了props或state,在update阶段可能也需要清除老effect,执行新effect。如此一来,处理统一逻辑的函数就被分散在多个地方,导致组件复杂度的上升。
另外眼尖的同学会发现,为什么useBottom内部的useEffect的依赖项,在我们这个场景中不设置呢?滚动事件,不是应该mount的时候初始化就好了吗?按之前的理解,应该是写一个空数组[],这样滚动事件只绑定一次。然而如果我们真的这样写: useBottom(fetchAndSetQuery, [])
的话,就会发现一个大bug。 fetchAndSetQuery
中的query与list 永远都是初始化时的数据,也即是 { page: 0, size: 50 }
与 []
。结果就是每次滚动到底部,加载的还是第一页数据,渲染的也还是第一页数据([…[], …第一页数据])。
Why!!!
于是我又阅读了一次uesEffect的相关文档,揣摩了一番,终于大致领悟。
useState与useEffect的正确使用姿势
state永远都是新的值
这一点同我们过去class组件中的state是完全不一样的。在class组件中,state一直是挂载在当前实例下,保持着同一个引用。而在函数式组件中,根本没有this。不管你的state是一个基本数据类型(如string、number),还是一个引用数据类型(如object),只要是通过useState获取的state,每一次render,都是新的值。 useState返回的状态更新方法,只是让下一次render时的state能获取到当前最新的值。而不是保持一个引用、更新那个引用值。(这一段如果看不懂,就多看几遍,如果还看不懂,请评论区温柔的指出,我想想再怎么通俗的去解释)
读懂这个概念,并把这个概念作为hooks使用的第一准则后,我们就能清晰的明白,为什么上述代码中,如果useBottom
中的useEffect的依赖项设为空数组,则内部的state,也即query与list,永远都是初始值。因为设为空数组后,其内部的 useEffect 中的滚动监听函数 内执行的 fetchAndSetQuery函数,其内部的query与list,也一直是第一次render时 useState 返回的值。
而如果不是空数组,每次render后,useBottom
中的滚动监听函数,会重新解绑旧函数,绑定新函数。新的函数带来的是 最新一次render时,useState 返回的最新状态值,故而实现正确的逻辑。
正确认识依赖项
于是我们更能深刻的认识到,为什么useEffect的依赖项设置如此重要。其实并非是设置依赖项后,依赖变更会触发effect。而是effect本应该每次render都触发,但因为effect内部依赖了外部数据,外部数据不变则内部effect执行无意义。因此只有当外部数据变更时,effect才会重新触发。
所以科学的来说,只要内部使用了某个外部变量,函数也好、变量也好,都应该填写到依赖配置中。所以我们上述编写的 useBottom
与使用方法其实并不严谨,我们再review一遍:
const useBottom = (action, dependencies) => {
function doInBottom() {
// isScrollToBottom 的代码不贴了
return isScrollToBottom() && action();
}
useEffect(() => {
window.addEventListener('scroll', doInBottom);
// useEffect内部return的方法,会在下一次render时执行
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
// ...
useBottom(fetchAndSetQuery);
// ...
};
复制代码
我们可以明确知道两点不对的:
- 在这个场景中,useEffect明确依赖了
doInBottom
,因此,useEffect的依赖项至少应该填写doInBottom
。当然,我们也选择把doInBootom
写到useEffect内部中,这样这个函数就成了内部引用,而不是外部依赖。 action
是一个未知的函数,其内部可能包含了外部依赖,我们传递的dependencies
应该是满足action
的明确依赖的,而不是自己瞎想到底是不填还是空数组。当然,更粗暴的方法是,直接把action
作为依赖项。
所以最终科学的代码应该是:
const useBottom = (action) => {
useEffect(() => {
function doInBottom() {
return isScrollToBottom() && action();
}
window.addEventListener('scroll', doInBottom);
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, [action]);
};
const usePagination = (fetchApi) => {
// ...
useBottom(fetchAndSetQuery);
// ...
};
复制代码
偏要勉强
还是有些同学,不喜欢这个依赖项,嫌传来传去的太麻烦,那有没有办法不传?还是有一些办法的。
首先,useState 返回的setState可以接受一个函数,函数的入参即是当前最新的状态值。在刚刚滚动加载的例子中,就可以避免了 list
成为副作用的依赖。不过 query
依旧没办法,因为请求数据需要最新状态值。但如果我们每一页数据的数量是固定的,我们可以把页码状态封装在请求方法里,如:
// 利用闭包维持分页状态
const fetchNextPage = ({ initPage, size }) => {
let page = initPage - 1;
return () => fetchList({ page: page + 1, size }).then((rs) => {
page += 1;
return rs;
});
};
复制代码
然后我们的 useBottom
可以真的不管关心依赖了,只需要第一次render时绑定滚动事件即可,代码如下:
const useBottom = (action, dependencies) => {
useEffect(() => {
function doInBottom() {
return isScrollToBottom() && action();
}
window.addEventListener('scroll', doInBottom);
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, dependencies);
};
const usePagination = (fetchApi) => {
const [list, setList] = useState([]); // 初始列表数据为空数组: []
const fetchData = () => {
fetchApi()
.then((newList) => {
setList(oldList => [...oldList, ...newList]);
})
.catch(() => window.console.log('加载失败,请重试'));
};
useEffect(fetchData, []);
useBottom(fetchData, []);
return [list];
};
export default function List() {
const [list] = usePagination(fetchNextPage({ initPage: 1, size: 50 }));
return (...略);
}
复制代码
其实useState返回的setState还有一个小弊端。如果页面状态较多,在某些异步行为(请求、定时器等)的回调中的setState是不会合并更新的(具体可自行研究react状态更新事务机制)。那分散的setState会带来多次render,这必然不是我们想看到的。
解决办法就是 useReducer
,其执行后返回 [state, dispatch]
,基本类似redux中的reducer。其中state是复杂状态的合集,dispatch触发reducer后,返回一个全新的状态值。具体用法可以见文档。其中主要记住两点:
- dispatch本身是稳定的,不会随多次render而导致变化,且dispatch触发的reducer函数,其入参的state始终是当下最新值。所以若是新状态的设置依赖于旧状态值,通过dispatch来更新,也可以避免effect依赖外部state。
useReducer
返回的state(并非reducer函数中的入参state),依旧遵循useState那套逻辑,每次render中获取的都是全新值而非同一个引用。
既然有了useReducer,那有没有 useRedux
呢?抱歉,并没有。不过 Redux
目前已有issue在讨论其hooks的实现了。也有外国网友做了一个简版的 useRedux,实现机制也非常简单,自己也能维护。如果有全局状态管理的需求,也可以做一下代码的搬运工。
相信在19年,将会有很多基于hooks的工具甚至是hooks库的出现。通过对状态逻辑的抽象、更方便的状态管理、更科学的函数组合与拆分,最开始所说的动机第二点「难以理解的复杂组件」在将来可能真的可以更好的避免。
网友拓展
一些网友较为深入,觉得示例中的useBottom还可以优化。掘金用户@黄金大键客认为useBottom不应该关心业务逻辑,@IVLIU认为可以利用useRef(这也是一种减少uesEffect依赖的方式)保存状态,避免监听函数的无意义重建。我们尝试再改造一下:
const useBottom = () => {
const [isBottom, setIsBottom] = useState(false);
const isBottomRef = useRef(isBottom);
useEffect(() => {
function doInBottom() {
const scrollToBottom = isScrollToBottom();
if (scrollToBottom !== isBottomRef.current) {
setIsBottom(scrollToBottom);
isBottomRef.current = scrollToBottom;
}
}
window.addEventListener('scroll', doInBottom);
return () => {
window.removeEventListener('scroll', doInBottom);
};
}, []);
return [isBottom];
};
const usePagination = (fetchApi) => {
// 因为每次是请求下一页数据,所以现在初始页码为: 0
const [query, setQuery] = useState({ page: 0, size: 50 });
const [list, setList] = useState([]); // 初始列表数据为空数组: []
const [isBottom] = useBottom();
const fetchAndSetQuery = () => {
// 每次请求下一页数据
const newQuery = {
...query,
page: query.page + 1,
};
fetchApi(newQuery)
.then((newList) => {
// 成功后插入新列表数据,并更新最新分页参数
setList([...list, ...newList]);
setQuery(newQuery);
})
.catch(() => window.console.log('加载失败,请重试'));
};
useEffect(fetchAndSetQuery, [isBottom]);
return [list];
};
复制代码
这么一来,useBottom
的逻辑就非常清晰,只抛出是否到底部的状态,而且只会执行一次监听函数。
总结
探到这里,我个人对hooks已经基本有个数了。它脱离了我传统的class组件开发方式,对state的定义也不同于组件中的this.state,对effect的概念与处理需要更加清晰明了。
使用hooks的明显好处是可以更好的抽象包含状态的逻辑,隐藏的一些功能是基于hooks的各种花式轮子。当然其“不好的地方”是有一个明显的认知与学习成本,如果写的不好,更容易出现性能问题。整体而言,这虽然比不上几年前 直接操作dom 跃迁到** 数据驱动DOM** 这样的革命性变更,但确实是react内部明显的革命性成就。
不知道各位看完以后,未来是倾向于 函数式组件+hooks 还是倾向于 class组件。可以在评论区进行一下小投票。就我个人而言,我站hooks。
关于React副作用
有些同学可能会对「副作用」这个概念不理解。我简单的说一下我的看法。很多人都看过一个React公式
UI = F(props)
翻译成普通话就是:一个组件最终的dom结构与样式是由父级传递的props决定的。
了解过函数式编程的同学,应该知道过一个概念,叫「纯函数」。意思是固定的输入必然有固定的输出,它不依赖任何外部因素,也不会对外部环境产生影响。
react希望自己的组件渲染也是个纯函数,所以有了纯函数组件。然而真正的业务场景是有各种状态的,实际影响UI的还有内部的state。(其实还有context,暂时先不讨论)。
UI = F(props, state, context)
这个state可能会因为各种原因产生变化,从而导致组件的渲染结果不一致。相同的入参(props)下,每次render都有可能返回不同的UI。因此任何导致此现象的行为都是副作用(side effects)。比如用户点击下一页,导致页码与列表发生变化,这就是副作用。同样的props,不点击时是第一页数据,点击一下后,变成了第二页的数据or请求失败的页面or其他UI交互。
当然state是明面上影响了UI,暗地里,可能还有其他因素会影响UI。比如组件内运用了缓存,导致每次渲染可能都不一样,这也是副作用。
关于我们:
我们是蚂蚁保险体验技术团队,来自蚂蚁金服保险事业群。我们是一个年轻的团队(没有历史技术栈包袱),目前平均年龄92年(去除一个最高分8x年-团队leader,去除一个最低分97年-实习小老弟)。我们支持了阿里集团几乎所有的保险业务。18年我们产出的相互宝轰动保险界,19年我们更有多个重量级项目筹备动员中。现伴随着事业群的高速发展,团队也在迅速扩张,欢迎各位前端高手加入我们~
我们希望你是:技术上基础扎实、某领域深入(Node/互动营销/数据可视化等);学习上善于沉淀、持续学习;性格上乐观开朗、活泼外向。
如有兴趣加入我们,欢迎发送简历至邮箱:[email protected]
作者:蚂蚁保险体验技术
链接:https://juejin.im/post/5ca5ad46e51d4550f6668465
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。