标签: css

  • 移动网页高度自适应最佳实践

    移动网页高度自适应最佳实践

    移动 Web 开发就要在“螺蛳壳里做道场”。移动设备限于屏幕尺寸,不得已左支右绌,既要多呈现内容,又要保证功能不要缺失。普通内容类网页还好,自然往下滚就行了;开发 Web App 的时候,当我们因为某种原因,需要限制滚动区域的时候,就很难处理。

    这篇文章会分享我的一些经验,希望能节省大家摸索的时间。

    无用单位:vh, svh, dvh, lvh

    很早以前,我们就有了 vwvh 单位,分别指代视窗宽高的 1%。需要注意的是,移动浏览器的视口包含被各种组件占据的部分,所以 vw 就比较好用,因为没有干扰;但是高度上,被通知栏、地址栏等占用的“临时”空间就会成为我们的麻烦。

    为了妥善利用屏幕空间,在我们上下滚屏的时候,大多数手机浏览器都会把地址栏、工具栏、或者通知栏隐藏起来,这就导致浏览器的可视面积其实会不断变化。原本就没用的 vh 便更没用了……于是后面新增了 svhdvhlvh 三种 长度单位,但其实帮助不大,因为当我们需要限制容器高度的时候,通常来说就不能让页面自由滚动。

    因为这几个长度单位过于没用,所以我就不详细介绍了。感兴趣的同学可以看下 TailwindCSS 里的演示:https://tailwindcss.com/docs/height#viewport-height

    虚拟键盘则让这个问题雪上加霜,因为虚拟键盘的显示和隐藏都不会影响这几个长度单位,所以当我们需要手动控制容器高度、位置的时候,就会很难做。

    最佳实践:常规页面,交给浏览器

    首先我们要信任浏览器,能够留给浏览器处理的,尽量交给浏览器原生处理。

    比如,常规页面,长一点,留给浏览器自然滚动。文本框输入的时候,浏览器会自动聚焦和滚动,通常情况下没什么问题,基本体验有保证。

    最佳实践:输入框文字不小于 16px

    如果文本框 font-size 小于 16px,iOS Safari 下,当文本框获得焦点,Safari 会自动放大整个页面;而失去焦点的时候,页面并不会自动缩小到 100%,所以就很蛋痛。

    解决方案有几个:

    1. 取消缩放。会使得可用性评价恶化,不推荐。
    2. blur 时自动恢复 100%。增加特性就是增加 bug 的可能,我觉得能不用就不用。
    3. 保持字体大小。应该大部分时候都更简单有效。

    最佳实践:使用 dvh 并解决兼容问题

    虽然但是,当我们需要固定高度的时候,表示视窗净高度的 dvh 仍然是我们最佳选择。

    不过,在我写文章的现在,dvh 的兼容性不是很好,所以必须做好兼容性配置。我建议用 JS 结合 CSS 变量来做。在 <head> 里插入这段 JS:

    // 首先,判断是否支持 dvh 单位
    if (!CSS.supports('height', '100dvh')) {
      // 如果不支持,就定义 --app-height 为视口高度,即 window.innerHeight
      document.body.style.setProperty('--app-height', window.innerHeight + 'px');
      // 当屏幕缩放时,改变内容高度。因为 resize 事件触发很频繁,所以使用节流减少性能损耗
      let timeout;
      function onResize() {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          clearTimeout(timeout);
          document.body.style.setProperty('--app-height', window.innerHeight + 'px');
        }, 500);
      }
      window.addEventListener('resize', onResize);
    }
    

    然后定义 CSS 样式:

    :root {
      --app-height: 100dvh;
    }
    
    .h-dvh-app {
      height: var(--app-height);
    }
    

    如果使用 TailwindCSS,那么在配置文件里增加配置即可:

    export default {
      theme: {
        extend: {
          spacing: {
            'dvh-app': 'var(--app-height)',
          },
        },
      },
    }        
    

    最佳实践:使用 CSS 变量解决虚拟键盘

    只是限制高度为 100dvh,当虚拟键盘弹出之后,因为视口缩小,很可能会出现问题。此时 window.resize 事件也不会触发,所以我们应该侦听文本框的 focus 事件,动态改变容器高度;并在文本框 blur 之后,恢复高度。

    此时,我们可以借助 CSS 变量的“默认值”功能,即 var(--custom-value, --default-value) 来处理。当我们需要暂时的高度以应对虚拟键盘时,设置 --custom-value;之后,移除 —custom-value,恢复到预定义的 --app-height

    首先,修改 css,定义 --input-height: initial,这个值会被认为是空值而忽略。

    :root {
      --input-height: initial; 
      --app-height: 100dvh;
    }
    
    .h-dvh-app {
      height: var(--input-height, var(--app-height));
    }
    

    然后侦听输入框的 focusblur 事件:

    async function onTextareaFocus(): Promise<void> {
      // 桌面端忽略这个需求
      if (window.innerWidth > 640) return;
    
      // 给虚拟键盘弹出一些时间
      await sleep(300);
      const { innerHeight } = window;
      document.body.style.setProperty('--input-height', `${innerHeight}px`);
      // 需要的话,可以在这里插入一个滚动
    }
    async function onTextareaBlur(event: FocusEvent): Promise<void> {
      // 同样,也给虚拟键盘收起留一些时间
      await sleep(250);
      document.body.style.removeProperty('--input-height');
    }
    

    总结

    至此,遵守以上最佳实践之后,基本上我们可以妥善处理移动网页里的浏览器高度。当然,并不完美,比如,iOS Safari 在输入 position: sticky 里的文本框时,会凭空多出一大块空白,很烦,但是没办法解决。可以绕开,但是我觉得绕开的方案更难用。

    希望这篇文章对大家有用。如果你对移动网页开发有什么问题,欢迎留言讨论。

  • CSS 小教程:在网格型选择工具上添加渐变背景

    CSS 小教程:在网格型选择工具上添加渐变背景

    新年开始了,要努力,本博客开始 2024 年招商,欢迎各位想推广产品的老板投广告,目前定价 4800/年,亦可增加评测文章、教学文章配合,详情可与我联系。具体详情更新在 本站广告 2024 年招商


    好几个月之前,我遇到这样一个需求:

    这个东西叫作 Emotional Scale,是一大堆描述情绪的形容词,存在从积极到消极的渐进关系,老板希望我们用颜色给予它们比较明显的区分。于是我就想到这样一种表现方式:

    1. 用网格化来布局每个形容词
    2. 给它们添加从上至下的渐变背景
    3. 再让 ChatGPT 给每个形容词选一个 emoji

    最后一项不用多说,直接丢给 ChatGPT 就行。这里重点说下前面两个。

    网格化布局用 display:grid + grid-template-columns: repeat(N, minmax(0, 1fr)) 即可,其中 N 可以根据宽度调整成需要的列数。

    接下来处理渐变。这里的难点在于,每个小格子是独立的,要维持统一的渐变比较困难。我想到两个方式:

    1. 画一个大的渐变背景,然后用 clip-path
    2. 通过计算的方式给每个小格子加渐变背景

    后面我决定选择第二种方式。我总共有 38 个格子,一行 4 个的话,就是 10 行;有 6 个颜色,刚好每两行是一个过渡。于是一行就应该是 0 -> 50%,如何渐变一半呢?我尝试了一下,发现可以用 -100% 和 200% 来做。

    于是我就配合 Vue 开发了这个组件:

    <script lang="ts" setup>
    function getBgColor(index: number): string {
    const colors = ['#1B4397', '#648EF7', '#5DC264', '#FFAE43', '#F15146', '#b91c1c']; // 总共 6 个颜色
    const start = index / 8 >> 0; // 8个=2行,就是两行完成两个颜色的过渡
    const end = start + 1;
    const line2 = index % 8 >= 4;

      // 借助 CSS 变量完成过渡效果
    return `--tw-gradient-stops: ${colors[start]} ${line2 ? '-100%' : ''}, ${colors[end]} ${line2 ? '' : '200%'}`;
    }
    </script>

    <template lang="pug">
      .grid.grid-cols-4.gap-2
    label.flex.flex-col.justify-center.items-center.bg-base-100.rounded-3xl.w-full.aspect-square.cursor-pointer.bg-gradient-to-b(
    v-for="([label, face], index) in todayDoingsScale"
    class="hover:bg-base-200"
    :style="getBgColor(index)"
    :key="index"
    @click="doSelect"
    )
    input.hidden(
    type="radio"
    name="feel"
    :value="label"
    v-model="score"
    )
    </template>

    最终完成的效果就如同上面截图所示。

    CSS 作为前端开发的必备技能,搞起来别有一番乐趣。新特性不断被添加进来,就有越来越多的功能可以实现,有越来越多的功能可以更容易的实现。相信我们下一步的开发环境和产品体验会更好。

    如果各位读者对上面的 CSS 实现有什么问题,或者有其它关于 CSS 的问题,欢迎留言提问。

  • 【视频】如何快速修 bug+如何开发低代码树状图

    【视频】如何快速修 bug+如何开发低代码树状图

    感谢 Latteat 老板上舰。

    有位同学在开发中遇到了一些问题,花费了很多时间也没能解决。其实修 bug 是有一些技巧的,通过改善内部变量的可观测性,可以很好的提升修 bug 的效率。同时,多多利用浏览器的渲染机制,减少自己手动计算的代码量,也能降低出 bug 的几率。

    希望大家都能从视频中学到东西。如果你有什么问题,欢迎在评论区提出。

    视频大纲:

    1. 开场白,介绍问题
    2. 加强可观测性,找到 bug
    3. 开发树状图:DOM 结构
    4. 开发树状图:添加样式
    5. 开发树状图:调整节点位置
    6. 总结:四个要点

    有任何问题、意见、建议,欢迎留言弹幕私信与我交流。另外,我现在还在组织模拟面试,想参加的同学,也请跟我联系。目前下周还是空缺,欢迎投稿。

  • 小教程:React 创建支持切换效果的导航组件

    小教程:React 创建支持切换效果的导航组件

    需求

    需求不复杂,如上图所示:

    1. 导航栏,里面有若干个链接按钮
    2. 点击切换的时候,会有一个高亮标记随着选中的按钮移动
    3. 按钮尺寸不确定,高亮标记的尺寸随着选中的按钮变化

    选择技术方案

    我的第一想法是 CSS 纯原生,这样使用起来会非常简单。但是考察之后,发现不太可行,难点在于:

    1. 高亮标记如果跟着按钮走,无法处理按钮间切换
    2. 如果放在父容器里,可以在按钮间切换,但是尺寸没法跟着按钮走

    所以短暂思考之后,我只能退而求其次,选择和框架结合的开发方式:高亮标记使用绝对定位来控制位置;当用户切换导航(点击按钮)的时候,使用 JS 获取目标按钮的位置和尺寸,并且赋给高亮标记;使用 CSS 变量控制动画,以提升代码的复用性,确保 CSS 规则不要太死硬。

    这个过程并不复杂,我们可以利用事件冒泡机制,获取被点击的按钮,然后利用 getBoundingClientRect() 计算坐标。

    CSS

    CSS 部分比较简单,我们用一个伪元素作为高亮标记,然后配合 CSS 变量移动它即可。唯一需要注意的是,伪元素要跟按钮重合,所以必须用到 z-index,按钮也必须手动设置成 position: relative

    (WP 代码高亮插件挂了,大家将就看……)

    .slide-navigator {
    --highlight-x: 0; /* 标记的 x 坐标 */
    --highlight-width: 0; /* 标记的宽度 */
    position: relative;

    &::before {
    content: '';
    position: absolute;
    z-index: 0;
    bottom: 0;
    left: var(--highlight-x);
    width: var(--highlight-width);
    background: #ffd850;
    transition: all 0.2s;
    }

    > * {
    position relative;
    }
    }

    这里暂时没考虑响应式,如果要切换横纵,可以增加一个 Y 坐标。如果需要定制样式,可以将高亮标记的样式抽出来单独处理。

    React JSX 组件

    作为组件,导航里面的按钮要跟着业务逻辑走,最好在业务组件里生成,然后通过 props.children 传进来。点击事件可以直接交由父容器,也就是本组件侦听,然后利用 event.target 来判断选中的是哪个按钮。

    这里还有一个抉择,即怎么确定高亮按钮是哪个。我考虑了一下,觉得将这个逻辑一并放在业务组件里比较好,然后通过 props.currentIndex 传进来使用。这样能确保本组件的复用性。坏处就是每个用到这个组件的地方都要写一个函数来判断,略嫌麻烦。

    最终组件代码如下:

    (WP 代码高亮插件挂了,大家将就看……)

    import {
    CSSProperties,
    FC,
    MouseEventHandler,
    useEffect,
    useRef,
    useState
    } from 'react';

    interface SlideHighlightProps {
    children: React.ReactNode;
    className: string;
    currentIndex: number;
    }

    type SlideNavigatorHighlight = CSSProperties & {
    '--highlight-x'?: string;
    '--highlight-width'?: string;
    };

    const SlideHighlight: FC<SlideHighlightProps> = function (props) {
    const { className, children, currentIndex } = props;
    const root = useRef<HTMLDivElement>(null);
    const [navStyle, setNavStyle] = useState<SlideNavigatorHighlight>();

    /* 处理按钮点击事件,找到被点击的按钮,并将其位置和宽度赋给高亮标记 */
    const onClick: MouseEventHandler<HTMLDivElement> = (event) => {
    if (!root.current) return;

    const target = Array.from(root.current.children).find((v) =>
    v.contains(event.target as Node)
    ) as HTMLElement;
    const { left } = root.current.getBoundingClientRect();
    const { left: l, width } = target.getBoundingClientRect();
    setNavStyle({
    '--highlight-x': `${l - left}px`,
    '--highlight-width': `${width}px`
    });
    };

    /* 组件初始化时,或者导航切换时,改变高亮标记的位置 */
    useEffect(() => {
    if (!root.current) return;

    /* 如果导航不应该高亮,就把标记隐藏起来 */
    if (currentIndex === -1) {
    setNavStyle({
    '--highlight-width': `0px`
    });
    return;
    }

    const { left } = root.current.getBoundingClientRect();
    const target = root.current.children[currentIndex] as HTMLElement;
    const { left: l, width } = target.getBoundingClientRect();
    setNavStyle({
    '--highlight-x': `${l - left}px`,
    '--highlight-width': `${width}px`
    });
    }, [currentIndex]);

    return (
    <div ref={root} className={'slide-navigator ' + className} style={navStyle} onClick={onClick}>
    {children}
    </div>
    );
    };

    export default SlideHighlight;

    总结

    我的 React 经验不太多,不考虑项目结构倒也问题不大,但是抽象组件的经验比较少,这次也是在 ChatGPT 帮助下才写好,所以赶紧趁热发一篇笔记。

    如果你对 React 经验比较丰富,发现我上面的代码有问题,欢迎指出。如果你对 React 开发有问题,对组件开发有疑问,也欢迎留言讨论。

  • 值得关注的 CSS 新特性

    值得关注的 CSS 新特性

    CSS 作为前端的重要组成部分,虽然受瞩目的程度逊于 JS,但是也在不停地进步。CSS 可以让我们的开发环境更好,用户体验更佳,所以大家也需要保持关注。这里记录一些值得关注的新特性(评判标准由我主观决定),有些已经实装,可以取代旧样式,提供更好的效果;有些还没有普及,但是可以逐步应用到我们的产品当中,渐进式增强。

    overflow-wrap

    这个属性我还是通过 TailwindCSS 学到的。它可以用来处理文本换行,拥有 3 个可选值:

    1. overflow-wrap: normal 默认值,按照标准模式换行
    2. overflow-wrap: anywhere 可以在任意处换行
    3. overflow-wrap: break-word 尽量利用宽度,只在超宽时打破单词换行

    我们知道,中文一个字就是一个字,随时可以换行;而英文则把一个单词算作一个字,单词内部不能随意断开。但是有时候,容器宽度不够,文字就会撑破容器。以前我们只有 word-break: break-all,但是 word-break: break-all 会在单词的任意位置断开,甚至在不必要的时候断开;而 overflow-wrap: break-word 会尝试把单词放到独立一行,如果还是不行,那就再断开。所以它的效果会更好。推荐大家使用。

    Scoped CSS

    CSS 的优先级非常重要,平时常常用到,面试的时候也会常常问到。CSS 优先级分成若干层级:

    1. #id
    2. .class
    3. element
    4. [attr="value"]
    5. :pseudo-class

    这样的优先级规划在大部分场景下,都能正常工作,但是当我们需要使用公共仓库,或者开发公共仓库的时候,就会遇到困难。

    比如 Awesome Comment 项目,它是一个嵌入页面的评论框,用户可以把它嵌入到自己的网站当中,给自己的网站添加评论功能。我们使用 TailwindCSS + DaisyUI 作为样式库,直接使用一切都好,但是当我们要把它嵌入别的网页时,就可能跟目标页已有的样式产生各种冲突。于是,在 Scoped CSS 尚未普及的今天(主要是 Safari 不支持),我们就必须给所有样式加上 ac- 前缀,非常影响开发效率。还有一位同学,他想给现在使用 bootstrap 的项目里引入 TailwindCSS,也面临严重问题。

    如果有了 Scoped CSS,我们只需要这样:

    @scope (.awesome-comment) {
      .a { }
    }

    就可以非常轻松地让我们的样式只在 .awesome-coment 节点内生效,且盖过其它样式。可以大大提升我们集成别人的样式,或别人使用我们样式时的效率。相信以后这个功能会大大改善第三方框架的开发。

    更多更详细的用法,建议大家深入阅读:@scope – CSS: Cascading Style Sheets | MDN (mozilla.org)

    <textarea> 根据内容自动缩放

    这项目功能目前还在讨论之中:[css-ui] ? Allow <textarea> to be sized by contents. · Issue #7542 · w3c/csswg-drafts (github.com)。大概意思是,<textarea> 用途非常广泛,让它能够根据内容长度自动调整大小会非常有价值。不过,具体选用什么方案,目前还没有定论。

    我搜了一下,目前无论 form-sizing 还是 field-sizing 都还没有进入规范,也没有间接实现方案(比如 PostCSS),所以大家先保持关注吧。

    View Transition 视图切换动画

    View Transition 让我们的产品可以在页面切换时,复用一些页面元素,展现出漂亮的动画,让用户更加理解页面逻辑。如下面这则视频所示,当用户点击列表中的链接时,大标题、作者等信息会以动画形式,移动到页头位置;当用户回到列表页的时候,它们又会移回列表里的相应位置。

    使用 View Transition 的方法比较复杂,我暂时还没有 Demo,所以就不详细说明了,大家感兴趣的话,可以看下面两个链接:

    1. Getting started with View Transitions on multi-page apps | daverupert.com
    2. https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API

    light-dark() 函数

    light-dark() 函数可以接受两个参数,分别作为 light 模式和 dark 模式下生效的属性。如以下代码所示:

    body {
      background-color: light-dark(white, black);
    }

    不过我觉得这个函数的作用不会很大,我还是喜欢集中定义和管理这些变量。

    color-mix() 函数

    相对来说,我觉得 color-mix() 函数的作用就大多了,它可以基于一个颜色,生成新的值。比如,我们先约定一批颜色,分别作为 primarysecondarysuccesserror 等,然后使用 color-mix 函数,生成一些衍生颜色,以便适用在 disabledactivehighlight 等场景下。

    使用方式也比较简单,它接受 3 个参数:混合模式,颜色1,颜色2。比如下面的代码,就是两种颜色各取一半,以 srgb 模式混合,得到新颜色。其中百分比也可以调整,比较好理解。

    /* 减淡,即混合白色 */
    color: color-mix(in srgb, var(--primary) 50%, white);
    /* 加深,即混合黑色 */
    background-color: color-mix(in srgb, var(--primary) 50%, black);

    有兴趣的同学可以深入了解:color-mix() – CSS: Cascading Style Sheets | MDN (mozilla.org)

    原生嵌套 CSS

    嵌套 CSS 相信大家在使用预处理工具的时候都会用到:

    .a {
      .b {
        .c { }
      }
    }

    因为确实有用,所以如今原生 CSS 也开始支持嵌套,可惜有些浏览器还没有支持。我建议大家先配合 TailwindCSS 使用,等到将来都支持了,再去掉 TailwindCSS 即可。

    export default {
      plugins: {
        'tailwindcss/nesting': {},
        tailwindcss: {}, // 不需要 TW 的话可以不要这行
        autoprefixer: {},
      },
    }

    详细规范请继续阅读:https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_nesting/Using_CSS_nesting

    CSS 容器查询

    我猜大部分同学都用过 @media query,以适配不同设备和分辨率。但我们现在已经是组件化开发的时代,很多时候,我们需要让自己的组件能够适配不同的使用场景,于是仅仅根据分辨率调整布局就不够了。万一被用在桌面显示器上,但是本身只占据一小块空间,那就无法保证用户体验。

    这个时候我们可以使用 CSS 容器查询(Container query),它的用法很简单,首先,假设我们的组件渲染出来的 HTML 是这样的:

    <div class="post">
      <div class="card">
        <h2>Card title</h2>
        <p>Card content</p>
      </div>
    </div>

    接着,我们要把目标容器标记为“容器控制上下文”(containment context)。未特意标记的容器不会产生容器查询,我猜这样可以减轻浏览器布局的计算负担。最后,使用 @container 查询,让容器在某个尺寸时,内部某些样式生效,即可:

    .post {
      container-type: size;
    }
    
    /* 默认样式 */
    .card h2 {
      font-size: 1em;
    }
    
    /* 仅在容器 >= 640px 时才生效的样式
    @container (min-width: 640px) {
      .card h2 {
        font-size: 2rem;
      }
    }

    容器查询还有一些进阶用法,大家可以深入阅读:CSS container queries – CSS: Cascading Style Sheets | MDN (mozilla.org)

    总结

    好的,先写这么多。感谢所有标准贡献者、开源软件开发者、社区参与者,我们 Web 开发者能收获现在的成就,跟所有贡献者的辛勤工作分不开。

    希望这些新特性能尽早进入实装,成为我们开发时的利器。

    如果你有什么想补充的,或者有问题想讨论一下,欢迎留下评论。

  • 2023 告别 CSS 预处理工具,彻底拥抱 TailwindCSS

    2023 告别 CSS 预处理工具,彻底拥抱 TailwindCSS

    CSS 是声明式语言,很简单,很好学,但是写起来很累,所有东西都要写出来才能生效。复用方面更是无从下手,虽然大家都在不断总结,但始终没能找到足够好用的方案,可以有效改善 CSS 开发。

    于是我们只好把视线转出 CSS 之外,转投 CSS 预处理工具,Less、SASS(SCSS)、Stylus,引入种种 CSS 不具备的功能,帮助我们改进开发体验。比如嵌套、函数、循环、条件,等等。然而如果你细心观察,实际上,这几个工具最近 5、6 年都没怎么更新(我说的是功能性),因为该有的都有了,甚至很稳定;其它来自于 CSS 的改进,几乎跟它们没什么关系,也不用更新。

    最近几年,随着 CSS 发展,一些新特性逐步引入,我觉得这些工具越来越难用,它们能带来的好处已经无法掩盖它们所造成的问题。是时候告别 CSS 预处理工具了,就像我们当年告别 jQuery 一样。

    为什么说预处理工具落后?

    我把理由分成三大类:

    预处理工具的问题

    • 对 CSS 函数兼容性不好,尤其是 rgba()hsl() 这些常用的颜色函数
    • 数值类型转换,有不符合预期的行为,比如 Stylus 实现 content:5

    CSS 的改进

    • CSS 拥有越来越多的函数,可以直接进行计算,比如前面提到的颜色;还有 calc() 来完成基础数学计算
    • CSS 变量非常好用,可以大大改进编程体验,配合各种 JS 框架,我们可以更容易的把数学逻辑和显示效果绑定在一起
    • CSS Houdini 可以实现新功能,即使不深入使用(JS 部分),也有好用的自定义属性
    • CSS 也开始从预处理工具吸收营养,比如近期的嵌套功能已经开始被整合,未来我们可以直接使用

    预处理工具无法跟进的问题

    • 很多缩写、复合属性无法处理,比如 background-imagebox-shadow 等,都支持多属性共同生效,预处理工具擅长的循环、条件、函数无法提供帮助。
    • 预处理,顾名思义,发生在生产之前。实际上,网页在实际浏览时,会有很多因素影响到渲染结果,比如分辨率、dark mode 等。预处理工具对这些需求也没有改进。

    替代方案

    我目前的替代方案基于 TailwindCSS,所以自然包含 PostCSS、AutoPrefixer 等工具。然后用 postcss-import 实现自动导入和模块化;使用 tailwindcss/nesting 实现嵌套。

    为什么选用 TailwindCSS?首先,实际开发中,不管使用什么前端框架,我们都需要大量原子化的胶水样式,比如调整间距、改变字号、给容器添加一些边框、圆角、阴影等。这些样式如果都手写,工作量并不小;学习不同的样式名也是负担;以及最重要的,CSS 优先级问题。使用 TailwindCSS 就都能很好解决。

    TailwindCSS 不仅包含一大堆原子化样式,自身也是个完整且优秀的 CSS 编译器。它包含 reset,提供一组全局通用的 CSS 变量;它可以从各种文件里把我们用到的样式提取出来,构建后生成的 CSS 里只有我们要用到样式,不会有多余的;它会分析我们对样式的使用,合理的调整样式顺序,保证样式能正确生效。使用 TailwindCSS 可以节省很多时间。

    它还自带若干插件,比如解决嵌套的 tailwindcss/nesting,支持内容类元素的的 @tailwindcss/typography 等。使用这些插件也可以帮我们节省很多时间。

    最后,TailwindCSS 的生态不断成长,我们的选择范围越来越宽:HeadlessUI、DaisyUI、付费的 Tailwind UI 等。方便我们从产品生命周期的任意阶段开始集成。

    推荐项目配置

    启动项目的时候,安装依赖。包含 PostCSS + AutoPrefixer、TailwindCSS 和 DaisyUI。前者提供 CSS 处理框架,包含自动导入 css 和嵌套功能;后两者提供可见的 UI。

    pnpm i postcss postcss-import tailwindcss autoprefixer daisyui -D

    自动初始化配置,-p 会自动生成 PostCSS 配置:

    pnpm tailwindcss init -p

    调整 postcss.config.js,启用 postcss-importtailwindcss/nesting。目前我们常用的嵌套规则和 CSS 规范略有区别,不过无所谓,规范也没确定,所以这样就足够了。

    module.exports = {
      plugins: {
        'postcss-import': {},
        'tailwindcss/nesting': {},
        tailwindcss: {},
        autoprefixer: {},
      },
    }

    然后调整 tailwind.config.js

    const DaisyUI = require('daisyui');
    // 这个插件可以帮我们处理文档类内容,我建议常用
    const Typography = require('@tailwindcss/typography');
    
    module.exports = {
      // 从以下文件查找用到的样式
      content: [
        './index.html',
        './src/**/*.{js,ts,jsx,tsx,vue}',
      ],
      theme: {
        extend: {
          // 扩充 TailwindCSS 没有包含的样式
        },
      },
      plugins: [
        DaisyUI,
        Typography,
      ],
      daisyui: {
        themes: [{
          // 只构建一个主题: luxury,并覆盖其中的两个属性
          luxury: {
            ...require('daisyui/src/colors/themes')[ '[data-theme=luxury]' ],
            primary: '#FFA028',
            '--bc': '0 0% 87.5%',
          },
        }],
      },
    }
    

    然后,创建样式入口 main.css。其它样式可以如常写在这个文件里,不过如果要 @import 其它 CSS 文件,就要进行一些调整。具体可以看官方文档。

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    然后在入口文件引用 main.css 即可:

    import './main.css';

    至此,新项目配置完成,可以照常开发了。

    下期预告

    这次我先分享了整体思路:用新的工具链替代预处理工具,保证已有的功能不缺失。那么下期分享的内容就是使用新的 CSS 特性,更好的完成开发。


    如果你对新 CSS 感兴趣,对预处理工具和新工具链有兴趣和疑问,欢迎留言讨论。如果本文对你有启发,也请帮我点赞分享,谢谢。


    本文参与了 SegmentFault 思否写作挑战赛,欢迎正在阅读的你也加入。

  • 聊聊前端入门(1):HTML+CSS

    聊聊前端入门(1):HTML+CSS

    最近有一些新老同学入门前端,找我问问题,我从他们身上发现了一些共性问题,今天拿出来总结一下,希望后来者能吸取经验。

    前端三大件:HTML、CSS、JavaScript。这三位是根本,万变不离其宗,不管用多先进的技术,最终浏览器里跑的还是他们(不考虑 WASM、WebGL)。所以前端入门应该以这三种语言为基础,慢慢扩展到其它领域。

    当然有些同学可能上来就用框架,拜现代化前端技术所赐,有些同学可能完全没认真学习过这三门语言,也怼出了功能、甚至怼出了产品。但我建议,无论从上限考虑还是从下限考虑,都应该好好学习这三门语言本身。

    声明式语言

    入门一般都从 HTML、CSS 开始,因为它们很简单,简单的原因是因为它俩是声明式语言。那什么是声明式语言呢?标准定义咱们略过不谈,简单来说有两大特点:

    1. 需要什么你就写什么,需要这里有一个文字,那就写一个文字;需要有一张图片,就写一张图片。
    2. 不写的就不会出现。没有逻辑推演、计算,不会因为环境变量而有所不同,没写的就没有。

    于是它们有好处也有坏处:

    • 好处:没有心智负担,不用理解全局,就看某一行、某一段,写了就是有,没写就是没有。
    • 坏处:需要 100 个字就要写 100 个字。有 100 个元素共用一个属性,可能也要写 100 遍。

    如何学习 HTML+CSS

    有些同学上学期间学过编程,比如 C 语言、Java 语言。它们都是命令式语言,有更强的逻辑性,它们就像一本推理小说,你必须从头看到尾,不漏过一个线索,层层推理,才能得到结论。

    他们会套用自己在命令式语言方面的经验去学习 HTML+CSS,然后发现,好难……命令式语言的语法元素很少,难在组合出合适的逻辑、设计合适的数据结构。声明式语言能实现的效果总数是固定的,没有预先设计的部分,就是实现不了,谁都实现不了。但是每种效果的实现可能都是不同的,需要大量经验和记忆。用上一段的类比,声明式语言就像诗歌,甚至上一行跟下一行都没联系,但是连到一起还真有点意思。

    所以学习 HTML+CSS 就要有合适的方法。我个人的经验如下,

    1. 首先要知道

    HTML 标签 100+,CSS 属性 200+,这些都是知识性的内容,不知道就是不知道;不知道但是要用,那就做不出来。所以第一步是知道这些内容,方法大概是:

    1. 浏览文档,比如 MDN
    2. 自己实验,加深记忆和理解
    3. 跟背单词一样,要经常性重复这个过程,才能真的记住

    这个过程不需要了解的很透,重点在于知道这些标签和属性的存在,当你面对需求的时候,才会想到解决方案。

    2. 接下来要多尝试

    经常浏览 codepen.io 这样的网站,上面有很多别人做的范例,可以长很多见识,让你恍然大悟,原来可以这样做那个东西。

    同时,也可以反过来用看到的范例指导自己的学习。比如,看到一个 css 规则,这个你刚好不熟悉,就可以找来文档仔细阅读理解一下,或者在论坛上询问其他前辈。

    一段时间之后,相信你会对大部分 html、css 属性都了如指掌,看到人家的网站,也大概知道该怎么做。但是给你一个实际项目,你可能还做不出来,或者做不好。没关系,那是下一步的目标。

    3. 紧跟社区步伐

    跟其它语言不一样,声明式语言从诞生开始,语法上不会有太大变化,语法元素也很固定。升级换代主要来自增砖添瓦,即新规则、新属性。

    比如 CSS,早年我们布局只能依靠 float。很难用,有各种诘屈聱牙的概念需要记忆。后来就有了 display:flex,也就是弹性盒模型,好用的多。后来又有了网络布局。然后因为各种内部弹性外部弹性,又有了 min-contentmax-contentfit-content 等新属性。

    这些东西,很新,有时候没有办法找到合适的学习资料。于是就要紧跟社区来,通过技术账号了解到新知识,通过技术文档学习新知识。

    4. 突破自己

    自学里面,最难的一件事其实就是突破自己。就好像我今天看到一个帖子,楼主说准备找到工作后就办张健身卡去健身,然后下面一群人歪楼,说“为什么健身要办健身卡?”

    因为自己监督自己很难,自己给自己打分更难。所以学校才要安排各种考试,给学生打分,让大家知道自己的位置。

    所以,做出来自己看觉得还行,没有用。这里有几个建议:

    1. 瞄准一个产品,比如微信,做到像素级复制。可以截图然后调到 50% 透明度,叠到一起慢慢看。
    2. 选择最合适的技术,坚持到底,不要因为某些环节难以突破就乱来。
    3. 邀请比较有经验的前辈帮自己做 code review

    附加:最好找个靠谱的前辈做引路人

    (我想了想还是把这条加上。)

    互联网是个好地方,我们能免费获取几乎所有有价值的资料。但是对于 Web 开发、前端开发这种领域,长期积累的海量知识储备也可能成为大家学习的障碍——东西太多,不知道从何处下手。

    所以,有个靠谱的引路人也很重要,会帮你节省很多时间。笔者不才,亦好为人师,有需要的可以找我:

    新人常犯的错误

    新人难免会犯错,这里列举一些常见的、可以规避的问题:

    1. 沉迷细节无法自拔

    有些同学沉迷细节,喜欢抠字眼,经常会提出一些奇奇怪怪的问题。很多问题其实没有准确答案,也不需要准确答案,尤其是对于新人来说,可能三两年内都不会真的需要解决。

    尤其是 HTML、CSS,因为是声明式语言,很多时候,它们的行为就是这么设计的,不需要也没办法深究;有些设计,可能来自二十年前甚至更早,现在早已没有那个应用场景,新人难以理解也是正常的。

    所以我建议大家以记用法积累经验为主,辅以实操验证,不需要像学命令式语言那样去强迫自己理解。

    2. 遇到问题生拼硬凑

    有些同学遇到问题就发慌,然后开始百度,看到一个方案就复制粘贴试一试,能行就继续,不能行就复制粘贴下一个。结果代码就补丁摞补丁,各种解决方案混合在一起,毛病越来越多。到最后可能完全超出掌控、无法解决。

    这里大家需要明白,语言元素很多,组合方式也很多。同样的问题,可能来自不同的根源;类似的组合,也会产生不同的问题。绝大部分时候,我们都要先选择方案,然后围绕方案组织代码,解决遇到的问题。在不同方案间左右横跳,最后结果多半不是好处均沾,而是问题集中爆发。

    如果能够找到最佳实践,那就坚持。如果几个方案都差不多,那就选好一个,坚持下去。遇到问题,面对多个搜索结果,要分析它们是怎么做的,解决了什么问题,不要直接拷过来试。坚持一段时间,会有很好的结果。

    总结

    我当年也是自学成才,走过不少弯路,希望这系列分享能帮大家节省一些时间。

    有任何问题和建议,欢迎留言评论。下次聊聊 JS 方面的学习。

  • 纯 CSS 实现优惠券效果

    纯 CSS 实现优惠券效果

    (本文不是广告,因为没给钱。)我厂 code.fun 上线了付费购买与优惠券功能,欢迎各位新老顾客莅临。

    上面是优惠券的视觉效果,本文分享如何使用纯 CSS 实现它,希望对大家有帮助。

    0. 分析

    首先,我们来分析一下这个优惠券的实现方案。

    左边是摘要,右边是详情,这个部分用 display:flex 很容易就能搞定。中间的虚线,使用任意一个容器边框 + 少许 padding 即可实现。其它部分,也就是些字体行高,都不是很复杂。难点在于投影,尤其是左右两个挖空的半圆。

    1. 挖空

    1. 首先,我们给整个优惠券矩形加上投影
    2. 然后,我们给两边加上两个包含内投影的圆形
    3. 这个时候,两边的内投影原型会多出来一块,我们需要把它们盖住。但是,不能让矩形 overflow:hidden,因为投影也会变,如下图。
    .coupon {
      width: 15rem;
      height: 6rem;
      background: white;
      box-shadow: 1px 1px 6px rgba(0,0,0,.15);
      position: relative;
      overflow: hidden;
      
      &::before,
      &::after {
        background-color: white; 
        border-radius: 1rem;
        box-shadow: inset 1px 1px 6px rgba(0, 0, 0, 0.15);
        content: '';
        width: 2rem;
        height: 2rem;
        position: absolute;
        top: 2rem;
        z-index: 1;
      }  
    
      &::before {
        left: -1rem;
      }
      &::after {
        right: -1rem;
      }
    }

    2. 增加父容器

    我的第一反应是增加父容器,让父容器 overflow:hidden 来隐藏多出来的部分。但是不行,会影响投影。

    但是转念一想,我们可以不让父容器限制显示内容,而是在父容器内部增加一些元素,遮蔽多出来的内容。比如用两个纯色圆形,把竖着的阴影遮起来。

    于是我把上面的样式更名为 .coupon-inner,然后增加一个父容器。父容器的 ::before ::after 伪元素都搞成略小一圈的纯色圆形,把竖着的投影挡住,最终效果如下图。

    3. 总结

    到这里,效果就基本让人满意了。

    最终完成的代码可以在 codepen 里看到:

    https://codepen.io/meathill/embed/oNqxOXE?default-tab=html%2Cresult&editable=true

    使用纯 CSS 的好处,在于体积小、加载快,调整起来非常灵活,能用 CSS 最好都用 CSS。

    有任何问题和建议,欢迎评论、讨论。

  • 使用 Vite 建立灵活的外部仓库

    使用 Vite 建立灵活的外部仓库

    0. Vite 与 ESM

    与 Webpack 不同,Vite 以 ESM 为其唯一的模块管理规范。首先,在开发环境,它会把每个文件编译成独立的 ESM 模块,实现非常快速的热加载。其次,编译打包时,它默认的目标环境是 ES2019,支持 ESM,所以模块打包后,也会使用 ESM 加载。

    这给我们带来一个好处:如果用 Vite 开发项目,并且对其进行分包,构建之后放到线上(比如发布到 NPM);接下来我们就可以在其它项目中,使用 ESM 方式加载这个项目的代码。

    举个简单的例子。lodash 有一个同步发布的 lodash-es 包,功能完全一致,只是使用 ESM 构建,我们可以直接在代码中 import forEach from 'https://unpkg.com/lodash-es@4.17.21/forEach.js' 引用。一方面可以节省我们自己的带宽;另一方面如果用户在其它应用里使用过同一个库,就可以提高速度。

    1. Vite 分包

    Vite 把构建过程委托给 Rollup,所以构建时分包需要传参给 rollupOptions

    在某个项目中,我需要整合一批 codepen 上的绘图效果,这些效果都放在 /src/effects/ 目录下,所以我就要检查这个目录,并且生成对应的分包配置。

    export default defineConfig(async () => {
      // 从 v10.10 开始,node.js 的 `fs.readdir()` 函数支持 `withFileTypes` 参数,使用这个参数可以直接返回 `fs.Dirent` 对象,类似使用 `fs.stat()` 得到的 `fs.Stats` 对象。方便我们判断对象类型
      const files = await readdir(effectsDirectory, {withFileTypes: true});
      // 把目录下的内容分为两类,一个是基础类库,一个是不同特效
      const [effects, baseFiles] = files.reduce(([effects, base], file) => {
        const {name} = file;
        if (file.isDirectory()) {
          effects.push(name);
        } else if (file.isFile()) {
          base.push(name);
        }
        return [effects, base];
      }, [[], []]);
    
      return {
        build: {
          rollupOptions: {
            manualChunks(id) {
              // effects/some-effect 下的文件按目录分别打包
              const effect = effects.find(effect => id.includes(`/${effect}/`));
              if (effect) {
                return effect;
              }
              // 效果基类打包成一个文件,因为效果只需要基类,所以从主体剥离
              const baseFile = baseFiles.find(base => id.endsWith(base) && !/p5/i.test(id));
              if (baseFile) {
                return 'BaseEffect';
              }
              // p5 是个很大的效果库,官方不提供 esm 包,只能单独打一个
              if (/p5/i.test(id)) {
                return 'p5';
              }
              // 其它依赖正常打包,只在本项目中使用,不会被引用
              return id.includes('node_modules') ? 'vendor' : 'chuck';
            },
          },
          // 这个第3节会解释
          target: 'es2020',
        },
      },
    }
    

    2. 去掉文件名中的 hash

    Vite 很贴心的帮我们给生成的文件都加上了 hash。在独立项目中,给文件名加 hash 可以有效避免缓存问题;但是作为外部仓库的话,无法确定的 hash 会增加业务项目的开发难度,所以我希望构建时输出到特定版本号的目录里,然后去掉文件名中的 hash。

    这个操作同样需要修改 rollupOptions。rollup 有三个不同的选项分别处理不同的命名,这里我们可以忽略入口文件(entryFileNames),只改剩下两个。

    export default defineConfig(() => {
      return {
        build: {
          rollupOptions: {
            output: {
              // 资源文件,包括 css
              assetFileNames: 'assets/[name].[ext]',
              // 分包文件
              chunkFileNames: '[name].js',
            },
          },
        },
      };
    });

    3. 动态加载 CSS

    使用 Vite 开发时,我们同样可以在代码里 import 样式等非 JS 素材。构建时,Vite 会把它们处理后放在合适的地方。

    可惜的是,Vite 并不会帮我们自动加载分包后的素材。需要我们手动处理。这时就要利用 import.meta.url,它会返回当前模块的 URL,配合前面的的文件名策略,我们就可以完成动态加载,而不需要业务项目的开发者手动处理。

    但是 Vite 默认的版本基线是 ES2019,并不支持 import.meta.url,所以我们需要把 build.target 设置成 ES2020 或以上。

    let isCssLoaded = false;
    
    // 只有未加载且处于生产环境才加载 css。
    if (!isCssLoaded && __IS_PROD__) {
      const link = document.createElement('link');
      link.rel = 'stylesheet';
      // 这一步非常重要,因为 vite/rollup 有 bug,会把 `import.meta.url` 翻译成 `self.location`,导致出错
      const baseUrl = import.meta.url;
      link.href = new URL('./assets/particle-orb.css', baseUrl).toString();
      document.head.appendChild(link);
      link.onload = () => {
        isCssLoaded = true;
      }
    }

    4. 总结

    新的技术选型总能给我们带来新的可能,ESM 之后,我们在项目之间复用代码也有了新的选择,赶紧用起来吧。

    如果你在使用 Vite 或者 ESM 时遇到什么问题,欢迎提问。如果有什么经验,也欢迎分享。

  • 正确使用 height: 100% 和 flex: 1

    正确使用 height: 100% 和 flex: 1

    HTML+CSS 实现页面布局的时候,盒模型是很重要的概念。早期没有明确布局概念的时候,HTML 元素主要分两大类:行(inline)元素与块(block)元素。默认情况下,块元素会占据父元素的一整行矩形区域,宽度是 100%,高度由内容决定。如果我们希望子元素跟父元素一样高,可以设置 height:100%

    接下来我们有了弹性盒模型, display: flex。弹性盒模型是一种主动布局,即我们先决定怎么布局,浏览器则负责填充内容和渲染。所以直觉上,我以为:

    1. 父元素 height: 100%; display: flex; flex-direction: column
    2. 某个子元素 flex: 1
    3. 子元素的子元素 height: 100%,应该能自动填充剩下的高度
    4. 如果子子元素同时 overflow: auto,那么应该可以自动出滚动条。

    结果不行。

    问题一

    如上图,我厂的 Showman 产品。它的高度自适应屏幕高度,顶部通栏、导航、动作按钮栏高度固定,编辑器和日志输出窗口填满剩下的空间。我希望用户可以调节编辑器和日志输出窗口的比例,以适应开发与调试不同的场景。于是保存日志输出窗口的高度,编辑器的高度自适应(flex:1)。

    因为 Vue2 组件要求有唯一的根元素,且整个应用有多个不同的路由,所以编辑器和日志输出窗口只能作为子子元素存在,大概的结构是这样的:

    <div id="app">
      <header id="main-nav">
      <!-- <router-view> -->
      <div id="main-body">
        <header id="second-nav">....</header>
        <div id="action-bar">....</div>
        <div class="editor-output">
          <div id="editor">....</div>
          <div class="drag-splitter"></div>
          <div id="output">....</div>
        </div>
      </div>
    </div>

    CSS 大概如此:

    html, body, #app {
      height: 100%;
    }
    #app {
      display: flex;
      flex-direction: column;
    }
    #main-nav, #second-nav, #action-bar {
      height: 40px;
    }
    #main-body, .editor-output {
      flex: 1;
      display: flex;
      flex-direction: column;
    }
    #editor {
      flex: 1;
    }
    #output {
      height: 100px;
    }

    这样的结果是,执行时,大量日志输出,就把界面顶开了。而不是预期中那样,出现滚动条,多余的部分被隐藏。

    经过一番 google,发现问题在高度计算。虽然定义了 height: 100%display: flex,但是浏览器在计算高度的时候,并不会从外往里一层一层算,而是按照规范:

    百分比
    指定一个百分比的高度。这个百分比是相对于父元素的盒子的高度计算的。如果父元素没有明确指定高度,并且该元素不是绝对定位的,该值将计算为 “自动”。

    auto
    由其它属性决定。

    于是因为上图中的界面存在嵌套关系,所以在需要计算高度的时候,子元素的高度虽然应该是 100%,但是父元素并没有被明确指定,所以就变成了 auto,继而被子元素撑开。解决方案就是沿着你需要 height: 100% 的元素往上,添加明确的 height,可以是百分比,也可以是绝对数值。

    于是,我在 #main-body 上添加 height: calc(100% - 49px),问题解决。

    😓 等下,不是每级都要加么?为啥只加一个具体高度就可以了?这个问题,我还要再研究一下。目前猜测,因为这个元素是竖直方向排列的(flex-direction: column)。

    问题二

    后来,在编辑器和日志输出窗口的右侧,增加了资源缩略图侧边栏。于是又遇到第二个问题:我以为 #main-nav 的高度确定,那么作为 display:flex,默认 align-items: stretch,它的子元素的高度应该都等于它的高度。所以给子元素设置 overflow: auto 就应该可以限制高度,出现滚动条。结果又失败了。

    然后我想起来 BFC。虽然直觉上 BFC 应该跟 display:flex 应该没什么关系,不过因为测试起来比较简单,可以先试试。

    于是我就在 div.d-flex 上添加了 .overflow-hidden 样式,果然问题就解决了。因为没找到明确的文档解释,所以我只能猜测:

    1. 类似 BFC 的逻辑在 display:flex 元素上依然存在。
    2. 父元素 display:flex;flex:N,根据上下文它应该有个确定的高度
    3. 但如果子元素高度超过它的高度,默认会撑开
    4. 如果父元素 overflow: hidden,会触发某个 xFC,于是整体高度就被限制了
    5. 于是子元素的滚动条就出来了

    总结

    行文至此,其实两个问题我都没找到具体的文档或者规范,只能说是摸索着解决了,然后再自己猜测原理。希望日后能找到具体的解释和规范吧。(要不要去翻翻张鑫旭的《CSS 世界》呢,都送完了,还得再买……)


    参考阅读: