缘起
ARTS 的终止
前端领域飞速发展,世界风云波谲云诡。学习资料堆积如山难以选择;计划总是跟不上变化。那么,作为前端浪潮中的弄潮儿,我们该如何选择,才能适应时代的变化呢?我想,我们也许可以做如下思考:
- 哪些事情做起来一定有益于个人的发展,如此,可避免总是在变化中无序选择;
- 只要一直做正确的事情,一直有经验的沉淀与思考,多年后的某一天,你会惊异地发现自己已经获得了超出预期的成长;
- 定点捕捞比广撒网更能获取你想要的知识。
同时,我认为,ARTS 打卡计划:
- 是职业生涯特定阶段的产物,比如它重算法、力求提升个人技术影响力;
- 以前的 ARTS 打卡,主要坚持的可能也只是 ATS,R 很少,实际上在某种程度上也难以发挥它的最大价值;
- 需要用这些时间系统性的干一些更重要的事儿,不仅仅是现阶段更重要的事儿;
- 知识积累过于宽泛,难有特定领域的深入探索和沉淀、产出;
- 我希望能一直做正确的事情,一直有经验的沉淀与思考,获得更多有深度的沉淀。
新的开始
- 周期:两周 ~ 两个月为一个周期
- 主题:知识与经验,探索和发现;
- 形式:
- Knowledge and Experience【阅读、识见、思考,不仅仅局限于专业知识,也包括生活、人生等】
- 最近两周学会的技术、小技巧和收获(可以含以前的算法)
- 技术文章阅读学习
- 格局、价值观、成长、人生类
- 财经、理财类
- 生活、运动健康等
- 不定期的实践增补
- Exploration and Discovery【探索与发现】
- 特定领域的技术和知识沉淀【定点捕捞】
- 技术文章输出,建议两周一篇
- 领悟与收获
- 不定期的实践增补
- Knowledge and Experience【阅读、识见、思考,不仅仅局限于专业知识,也包括生活、人生等】
两年为期,遇见更好的自己! —— 2021.05.24 By Cheney。
立春
截止 2021.07.23
Knowledge and Experience
background-image
问题
.body {
overflow: hidden; /* 这个可以禁止下拉和左右滑动 */
/* overflow-x: hidden; // 限制左右滑动位移 */
background: #201d32; /* 不设置background的话,会有白边,下拉会漏出白底 */
background-image: linear-gradient(to bottom, #201d32, #000000 100%);
}
- 移动端 web 禁止长按选择文字以及弹出菜单
/*如果是禁用长按选择文字功能,用css*/
* {
user-select: none;
}
// 如果是想禁用长按弹出菜单, 用js
window.addEventListener('contextmenu', function (e) {
e.preventDefault();
});
pointer-events
是 css3 的一个属性,指定在什么情况下元素可以成为鼠标事件的target
(包括鼠标的样式)。pointer-events
属性有很多值,但是对于浏览器来说,只有auto
(默认值)和none
两个值可用,其它的几个是针对 SVG 的(本身这个属性就来自于 SVG 技术)。none
值时,元素永远不会成为鼠标事件的target
(目标)。- 不要过度使用 React.useCallback()
- git 对比两个分支差异
git diff branch1 branch2 --stat // 显示出branch1和branch2中差异的部分
git diff branch1 branch2 具体文件路径 // 显示指定文件的详细差异
git diff branch1 branch2 // 显示出所有有差异的文件的详细差异
git log branch1 ^branch2 // 查看branch1分支有,而branch2中没有的log
git log branch1..branch2 // 查看branch2中比branch1中多提交了哪些内容。注意,列出来的是两个点后边(此处即dev)多提交的内容。
git log branch1...branch2 // 不知道谁提交的多谁提交的少,单纯想知道有什么不一样
git log -left-right branch1...branch2 // 在上述情况下,在显示出每个提交是在哪个分支上。注意 commit 后面的箭头,根据我们在 –left-right branch1…branch2 的顺序,左箭头 < 表示是 branch1 的,右箭头 > 表示是branch2的。
- opacity 子元素继承父元素透明度的解决方法(参考)
- 父元素背景颜色设置透明度时,避免使用
background:#000;opacity:0.5
,建议使用background:rgba(0,0,0,0.5)
- 如果设置背景色为渐变色等这种复杂背景,子元素会继承父元素的 opacity 属性,我们让它不成为子元素。新增一个子元素,将其绝对定位到父元素位置,然后在该元素上设置背景色与透明度。
- 父元素背景颜色设置透明度时,避免使用
- fixed 元素抖动问题
- 【移动端】解决 fixed 定位闪动问题:
transform: translateZ(0)
; - 移动端 fixed 的元素抖动的问题
- 【移动端】解决 fixed 定位闪动问题:
- 长背景,上部白底,下底有背景:
background-image: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000000 100%);
; - CSS 实现渐隐渐现效果
-
git cherry-pick 教程:
git cherry-pick
命令的作用,就是将指定的提交(commit)应用于其他分支。【修改错了分支,已经 commit,但是又不能直接合并分支】$ git cherry-pick <commitHash> # 将指定的提交commitHash,应用于当前分支 $ git cherry-pick feature # 将feature分支的最近一次提交,转移到当前分支 $ git cherry-pick <HashA> <HashB> # 将 A 和 B 两个提交应用到当前分支
-
0.5px
边框问题(部分三星机型):CSS 0.5px 细线边框的原理和实现方式要实现小于 1px 的线条,有个先决条件:屏幕的分辨率要足够高,设备像素比要大于 1,即 css 中的 1 个像素对应物理屏幕中 1 个以上的像素点。
// border-width: 0.5px; border-width: 1px; transform: scaleY(0.5);
-
rgba 的另两种写法:
background: rgba($color: #10152a, $alpha: 0.1);
和background: rgba(red, green, blue, alpha)
。 - 过滤字符串中的表情:
// emoji 范围
const emojiRanges = [
'\ud83c[\udf00-\udfff]',
'\ud83d[\udc00-\ude4f]',
'\ud83d[\ude80-\udeff]',
];
// emoji 正则
const emojiReg = new RegExp(emojiRanges.join('|'), 'g');
// 过滤掉表情
export const filterEmoji = (str: string): string => {
return str.replace(emojiReg, '');
};
-
Mac 设置环境变量
- 单个用户:
export PATH=/opt/local/bin:/opt/local/sbin:$PATH
- MAC 设置环境变量 path 的几种方法
- 单个用户:
- Fira Code,一个程序员专用字体
- Git 忽略规则(.gitignore 配置)不生效原因和解决
- git 清除本地缓存(改变成未 track 状态),然后再提交:
[root@kevin ~]# git rm -r --cached .
- 在每个 clone 下来的仓库中手动设置不要检查特定文件的更改情况。
# 在PATH处输入要忽略的文件 [root@kevin ~]# git update-index --assume-unchanged PATH
- git 清除本地缓存(改变成未 track 状态),然后再提交:
- 安卓调试
Exploration and Discovery
前端性能优化
走进浏览器的世界
雨水
截止 2021.09.23
Knowledge and Experience
- 查看当前的 git 分支是基于哪个分支创建的?:
git reflog --date=local | grep <branchname>
; - Less 循环简化样式的编写
/**
* 定义循环方法
* @index--传入的循环起始值
*/
.LoopTransform(@index) when(@index<6) {
// 执行内容
// 类名参数要加大括号@{index}
// 根据index获取对应的某个值
.swiper-wrapper .swiper-slide:nth-child(@{index}) {
/*index号图像向前方移位300px*/
// 注意,这里不能写成(72 * (@index - 1))deg
transform: rotateY(72deg * (@index - 1)) translateZ(300px);
}
//递归调用 达到循环目的
.LoopTransform(@index+1);
}
// 调用循环
.LoopTransform(1);
// 转换方法
const arrHex = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
]; //十六进制数组
/**
* 将颜色值转为带透明度的16进制
* @param param0
* @returns
*/
export const getHexOpacityColor = ({ color = '#000000', opacity = 1 }) => {
color = color.replace(/\#/g, '').toUpperCase();
if (color.length === 3) {
const arr = color.split('');
color = '';
for (let i = 0; i < arr.length; i++) {
color += arr[i] + arr[i]; //将简写的3位字符补全到6位字符
}
}
const opacityStr = getHexOpacity({ opacity });
return `#${color + opacityStr}`;
};
/**
* 透明度转16进制表示
* @param param0
* @returns
*/
export const getHexOpacity = ({ opacity = 0.5 }) => {
opacity = Math.max(opacity, 0);
opacity = Math.min(opacity, 1);
let num = Math.round(255 * opacity); //向下取整
// let str = num.toString(16); // 可以用这一行替代下面的循环
let str = '';
while (num > 0) {
const mod = num % 16;
num = (num - mod) / 16;
str = arrHex[mod] + str;
}
if (str.length == 1) str = '0' + str;
if (str.length == 0) str = '00';
return `${str}`;
};
/**
* 16进制透明度转小数
* @param param0
* @returns
*/
export const getDecimalOpacity = ({ hexOpacity = '00' }) => {
let opacity = Number((parseInt(hexOpacity, 16) / 255).toFixed(2));
opacity = Math.max(opacity, 0);
opacity = Math.min(opacity, 1);
return opacity;
};
// 16进制转RGB值
export const hexToRgb = (hex: string) => {
hex = hex.replace(/\#/g, '').toUpperCase();
if (hex.length === 3) {
const arr = hex.split('');
hex = '';
for (let i = 0; i < arr.length; i++) {
hex += arr[i] + arr[i]; //将简写的3位字符补全到6位字符
}
}
const bigint = parseInt(hex, 16);
return {
r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255,
b: bigint & 255,
};
};
// RGB值转16进制
// 其实 ((r << 16) + (g << 8) + b).toString(16)已经可以了,为什么前边还要加个 (1 << 24) 再做处理
// 解释:为了防止 r,g,b值全为 0 的特殊情况, ((1 << 24))的值二进制表示为 100...0(1后边有24个0),加上r(0),g(0),b(0),结果不变, ((1 << 24)).toString(16) 的值为 "1000000"
export const rgbToHex = ({ r, g, b }: { r: number, g: number, b: number }) => {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
};
/**
* 客户端xml色值转css
* 8位16进制色值,客户端透明度在前,css透明度在后
* @param xml
* @returns
*/
export const xmlColor2CSSColor = (xml: string = '') => {
if (xml.length === 7) xml = '#FF' + xml.substring(1);
return xml.substring(0, 1) + xml.substring(3) + xml.substring(1, 3);
};
/**
* css色值转客户端xml色值
* @param css
* @returns
*/
export const cssColor2XMLColor = (css: string = '') => {
if (css.length === 7) css += 'FF';
return css.substring(0, 1) + css.substring(7) + css.substring(1, 7);
};
-
使用通配符的属性选择器【
E[Attr^=Val]
】【E[Attr$=Val]
】【E[Attr*=Val]
】:css3 属性选择器中的大 boss,使得选择器功能分分钟提升。E[att^="val"]
,选择器匹配元素 E,且 E 元素定义了属性 att,att 的属性值是以 val 开头的任何字符串。E[att$="val"]
,选择器匹配元素 E,且 E 元素定义了属性 att,att 的属性值以 val 结尾的任何字符串。E[att*="val"]
,选择器匹配元素 E,且元素定义了属性 att,att 属性值任意位置包含了”val”。
-
Mac 查看端口号是否被占用及释放
- 提示信息:
Something is already running on port 8080. Use port 8081 instead.
- 查看使用端口进程:
lsof -i: 端口号
- 释放进程:
kill 你的PID
- 再次执行第一步,是否无进程占用:
lsof -i: 端口号
- 提示信息:
-
Support for password authentication was removed. Please use a personal access token instead [duplicate] | Token authentication requirements for Git operations
在 GitHub 创建个人 Access Token:从你的 Github 帐户,转到 Settings => Developer Settings => Personal Access Token => Generate New Token (Give your password) => Fill up the form => 单击 Generate token => Copy the generated Token,它将类似于
ghp_sFhFsSHhTzMDreGRLjmks4Tzuzgthdvfsrta
【生成 Token 时记得进行选择 Token 的权限Creating a personal access token,否则 push 的时候可能会 403】对于 MAC 操作系统,单击菜单栏右侧的 Spotlight 图标(放大镜,或者 command + 空格)。键入 Keychain access 然后按 Enter 键启动应用程序【钥匙串访问】 => 在 Keychain Access 中,搜索 github.com=> 查找 Internet 密码条目 github.com=> 相应地编辑或删除该条目 => 大功告成
对于 Windows 操作系统,从控制面板转到 Credential Manager => Windows Credentials=> 查找 git:https://github.com =>编辑=> 密码替换为你的 Github 个人 Access Taken => 完成
-
css 非阻塞的一种解决方案:当一个媒体查询的结果值计算出来是 false 的时候,浏览器仍然会下载样式表,但是不会在渲染页面之前等待样式表的资源可用。样式表一下载好,media 属性就必须被设置一个可用的值,以便样式规则能被应用到 html 文档中。onload 事件就可以用来将 media 属性切换到 all:
<!-- 方法1 -->
<link
rel="stylesheet"
href="css.css"
media="none"
onload="if(media!='all')media='all'"
/>
<!-- 方法2 -->
<link
rel="preload"
as="style"
href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
/>
<script>
{
`
let link = document.createElement('link')
link.setAttribute('rel', 'stylesheet')
link.setAttribute('href', 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap')
document.head.appendChild(link)
`;
}
</script>
- touch 事件中的 touches、targetTouches 和 changedTouches 详解
- 触点坐标选取:
touchstart
和touchmove
使用:e.targetTouches[0].pageX
;touchend
使用:e.changedTouches[0].pageX
。
- 触点坐标选取:
let startX: number;
let endX: number;
const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX;
};
const onTouchEnd = (e: any) => {
endX = e.changedTouches[0].pageX;
if (endX && endX && Math.abs(endX - startX) > 50) {
if (endX - startX > 0) {
// allowSlideNext allowSlidePrev 可以控制是否运行向对应方向滑动
swiperInstance?.slidePrev();
} else {
swiperInstance?.slideNext();
}
}
};
// 滑动事件 Hooks
export const useTouchEvent = () => {
// useRef 的内容发生变化时,它不会通知你
// 更改`.current`属性不会导致重新渲染。因为他一直是一个引用。
const startX = useRef(0);
const swiperInstance = useRef<any>(null);
const setSwiperInstance = useCallback((el: any) => {
swiperInstance.current = el;
}, []);
const onTouchStart = useCallback((e: any) => {
startX.current = e.targetTouches[0].pageX;
}, []);
const onTouchEnd = useCallback((e: any) => {
// 执行滑动逻辑
const endX = e.changedTouches[0].pageX;
if (endX && endX && Math.abs(endX - startX.current) > 50) {
if (endX - startX.current > 0) {
swiperInstance?.current?.slidePrev();
} else {
swiperInstance?.current?.slideNext();
}
}
}, []);
return { onTouchStart, onTouchEnd, setSwiperInstance };
};
- How to fetch data with React Hooks
- React swiperjs 使用:
- swiperjs
- swiper 中文文档
onSwiper
:接收 Swiper 实例的回调;- 在最外层的容器上增加
className="swiper-no-swiping"
可以禁止手动拖动滑动; - 宽度可以设置:
width={(window.innerWidth || 360) / 2}
;【强制 Swiper 的宽度(px),当你的 Swiper 在隐藏状态下初始化时用得上。这个参数会使自适应失效。】 onClick
可以这么用:const onClick = (e: any) => { if (e.clickedIndex > activeIndex) { swip?.slideNext(); } else if (e.clickedIndex < activeIndex) { swip?.slidePrev(); } };
-
点击时获取 DOM 上的值(属性):可以通过
e.target.dataset.xxx
获取元素上通过data-xxx
设置的值。 - PC、手机判断
const isMobile = () =>
navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
) !== null;
- Less 函数
// global.less
@default-w: 360;
.px2vw(@px, @width: @default-w) {
@var: (@px / @width) * 100;
@vw: ~'@{var}vw';
}
// index.less
@import url('global.less');
.radioButton_wrap {
display: flex;
flex-direction: column;
.radioButton {
position: relative;
label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
color: #fff;
}
input[type='radio'] {
// 去除radio小圆圈
appearance: none;
width: .px2vw(328) [ @vw];
height: .px2vw(56) [ @vw];
background: #000000;
box-sizing: border-box;
border: 1.5px solid #2e9df3;
border-radius: .px2vw(28) [ @vw];
}
input[type='radio']:checked {
background: #2e9df3;
}
input[type='radio']:checked {
color: #fff;
}
}
}
-
如何重置 Mac 的 SMC:重置系统管理控制器 (SMC) 可以解决某些与电源、电池、风扇和其他功能相关的问题。【如:电脑因出现问题而重新启动。请按一下按键,或等几秒钟以继续启动。】
-
在使用
Form.Item
获取表单数据的时候,千万要注意,对于最外层的标签,同级别的只能有一个。如:下面的 InputNumber 值会取不到,因为Form.Item
中还包含了“for each number”。
<Form.Item
label='Enter amount'
name='amount'
rules={[
{
required: true,
type: 'number',
min: 1,
max: 999,
message: 'Please input amount!',
},
]}
>
<InputNumber
precision={0}
placeholder='Please input amount!'
style=
/>{' '}
for each number
</Form.Item>
function FancyInput(props, ref) {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => {
inputRef.current.focus();
}
}));
return <input ref={inputRef} ... />;
}
FancyInput = forwardRef(FancyInput);
// 渲染 <FancyInput ref={inputRef} /> 的父组件可以调用 inputRef.current.focus()
-
Form 表单中,在黏贴(Paste)的时候对剪贴板内容进行格式化或者其他处理的方法:
- onPaste:弊端为难以追加黏贴,如果要实现追加黏贴,需要读取光标位置、
getSelection
等,会很麻烦;
onPaste: (e) => { // 阻止默认事件 e.preventDefault(); form.setFieldsValue({ // 获取剪贴板内容并格式化(如处理掉多余的空格、替换换行符等),然后设置给表单 phone_number: e.clipboardData.getData('text').replace(/\s+/g, ' '), }); };
- 结合 onPaste 和 onChange 实现:
onPaste: () => { // 加锁,避免无意义的form.setFieldsValue IsOnPaste.current = true; }, onChange: () => { // 只有是黏贴引起的Change才需要格式化 if (IsOnPaste.current) { // 解锁 IsOnPaste.current = false; form.setFieldsValue({ // 获取表单的值,处理后再设置回去 phone_number:form.getFieldValue('phone_number').replace(/\s+/g, ' ') }) } }
- onPaste:弊端为难以追加黏贴,如果要实现追加黏贴,需要读取光标位置、
-
less 插值:
- 法一:
.color(@token) { color: var(~'--color-@{token}'); } body { background: .color('abc') [color]; }
- 法二:
.color(@token) { @color: var(~'--color-@{token}'); } body { .color('abcd'); //调用 background: @color; // 使用返回值 }
- 法一:
Exploration and Discovery
前端装逼技巧
CSS
惊蛰、春分、清明
2021.11.23 2022.01.23 2022.03.21
Knowledge and Experience
- 防止越界的简写方式:
// 原写法
const nextValue = currentValue === 4 ? currentValue : currentValue + 1;
const preValue = currentValue === 0 ? currentValue : currentValue - 1;
// 新的写法
const nextValue = Math.min(currentValue + 1, 4);
const preValue = Math.max(currentValue - 1, 0);
-
在 flex 布局中,有时候会遇到,在缩小容器时,最左边的元素被挤压/ 压扁了,这时只需要给它添加一个 css 样式:
flex-shrink:0;
;理论参考:深入理解 flex 布局的 flex-grow、flex-shrink、flex-basisflex 有三个属性值,分别是
flex-grow
,flex-shrink
,flex-basis
,默认值是0 1 auto
。Flex 布局教程:语法篇flex-basis
:用于设置子项的占用空间;如果没设置或者为 auto;flex-grow
: 用来“瓜分”父项的“剩余空间”(拉伸);未设置默认为 0;flex-shrink
:用来“吸收”超出的空间(压缩);未设置默认为 1;
-
统计 excel 类表格中某列文字出现频率/频次并筛选:
const name2Obj = `若川
战场小包
陈_杨
cv竹叶
獨釣寒江雪
BraveWang
蓝色夜晚
西瓜watermelon
海的对岸
政采云前端团队
是洋柿子啊
前端小智
獨釣寒江雪
由也_`
.split('\n')
.reduce((pre, next) => {
return { ...pre, [next]: pre[next] ? pre[next] + 1 : 1 };
}, {});
// 按照频率倒序
Object.entries(name2Obj).sort((a, b) => b[1] - a[1]);
// 筛选出现大于1次的
Object.entries(name2Obj).filter((item) => item[1] > 1);
- 控制台解析 preview 和 response 数据不一致怎么解决
- css 画对号(打勾)
.tick {
width: 8.5px;
height: 16.5px;
border-color: #00adffff;
border-style: solid;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
-
CSV vs Excel (.xls) – What is the Difference? CSV 与 Excel (.xls) – 有什么区别?
- EasyExcel:EasyExcel 是一个基于 Java 的简单、省内存的读写 Excel 的开源项目。在尽可能节约内存的情况下支持读写百 M 的 Excel。
-
fetch API 获取返回值的方式:
const count = 3;
const fakeDataUrl = `https://randomuser.me/api/?results=${count}&inc=name,gender,email,nat,picture&noinfo`;
fetch(fakeDataUrl)
.then((res) => res.json())
.then((res) => console.log(res));
-
Mac 上录制视频快捷键:command ➕ shift ➕ 5;
-
表格吸顶的实现:
// 实现一
<Table
sticky
className={styles.table}
columns={columns}
dataSource={data}
pagination=
/>
// 实现二
<>
<Table
sticky
columns={columns as any}
dataSource={dataSource}
pagination={false}
loading={loading}
onChange={pageChange}
scroll=
/>
<footer className={styles.tableFooter}>
<Pagination
showSizeChanger
showQuickJumper
current={current}
pageSize={pageSize}
total={total}
onChange={(current, size) => {
setPageSize(size as number);
setCurrent(pageSize === size ? current : 1);
}}
/>
</footer>
</>
// 实现一
.table {
:global {
.ant-pagination {
position: sticky;
bottom: 0;
padding: 16px;
background-color: #fff;
width: 100%;
text-align: center;
z-index: 2;
border-radius: 5px;
}
}
}
// 实现二
.tableFooter {
position: sticky;
bottom: 0;
padding: 16px;
background-color: #fff;
width: 100%;
text-align: center;
z-index: 2;
border-radius: 5px;
}
Exploration and Discovery
- 使用 CSS Scroll Snap 优化滚动,提升用户体验!
- Introducing CSS Scroll Snap Points
- Practical CSS Scroll Snapping
- 大侠,请留步,要不过来了解下 CSS Scroll Snap?
- git rebase
- git rebase 详解(图解+最简单示例,一次就懂)
- Git 基本命令 merge 和 rebase,你真的了解吗?
- has been blocked by CORS policy: The request client is not a secure context and the resource is in more-private address space
private
- 配置 Chrome 选项
chrome://flags/#block-insecure-private-network-requests
为 disable - chrome 更新跨域规则,将对网站造成影响【原理】
- 配置 Chrome 选项
浏览器插件
WebView 相关
前端换肤
- 使用 css/less 动态更换主题色(换肤功能)
- link rel=alternate 网站换肤功能最佳实现
- 前端换肤的 N 种方案,请收下
- 一文总结前端换肤换主题
- CSS 变量教程
- Element-UI 换肤