关于 Innei | 拾一

数字游民,NodeJS全栈开发,前支付宝、小红书。

RSS 地址: https://innei.ren/feed

请复制 RSS 到你的阅读器,或快速订阅到 :

Innei | 拾一 RSS 预览

南京行、心境

2024-09-04 22:02:42

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/179

南京

前些天的突然决定,买了去南京的高铁票。就这样周末去了一趟南京。

没有任何行程,毫无计划。

第一站,去朋友家里。

出地铁后,看到了南京小米。

此图片不支持在 RSS Render 中查看。

然后是

https://x.com/__oQuery/status/1829828314003095747

晚上,去了夫子庙和秦淮河。

:::gallery 此图片不支持在 RSS Render 中查看。 此图片不支持在 RSS Render 中查看。 此图片不支持在 RSS Render 中查看。 :::

第二天,尝了鸡鸣汤包。好吃,一口下去都是汁,回家之后买点冷冻的当早饭吃。 此图片不支持在 RSS Render 中查看。

下午去水游城玩了,二次元浓度爆了。

:::masonry 此图片不支持在 RSS Render 中查看。 此图片不支持在 RSS Render 中查看。 此图片不支持在 RSS Render 中查看。 :::

短短两天,返程。

短暂的旅程,重要的不是景,而是人,友人。

Follow 进展

不知不觉全职开发 Follow 也已经有三个月了。这三个月也是贡献了非常多的代码。修改了大量的 UI/UX,增加了 N 多细节,但是还有非常多的不完善。虽然目前仍然处于内测状态,实际用户也不过几百,但是目前用户需求的呼声非常高,我们还需要更加努力去完善这个产品。

对了,Follow 预计在本周会推出邀请制,后续会慢慢扩大测试范围。欢迎大家前来体验。

https://github.com/RSSNext/Follow

焦虑的根源

今天,HR 通知我转正了。刚开始说让我做好心理准备,心由不得颤抖起来,大脑空白了。随后又说通过了。突如其来的反转,我一时间没有情绪,只是心里的石头放下了。

其实,我也不明白,为何焦虑。

是大环境寒冬,是孤独,是受周围人的影响,是害怕失业,还是其他。

归根或许都是没有安全感。

我很羡慕那些即便失去工作一样能够快乐的享受生活的人,而我一定只会沉浸在内耗之中。

而我,只是遇到了一次裁员,过了许久之后才慢慢缓过来。现在想想不由得还是有些后怕。

Mix Space 相关

印象中 Mix Space 已经许久没有增加新功能了。

近期突发奇想,把 Clerk 删掉了,现在内置了一套基于 Auth.js 的 Oauth 登录方案。有了一套自主的用户系统的话,后续阅读时间线之类的会更加有可玩性吧。拭目以待。


对了,在网友的推荐下,我还购买了一把椅子。是西昊的 C500。

看完了?说点什么呢

ShadowDOM 中样式隔离和继承

2024-08-30 22:18:24

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/ShadowDOM-style-isolation-and-inheritance

如果你了解 Web Component 那么你一定知道 Shadow DOM,Shadow DOM 是用于创建一个与外部隔离的 DOM Tree,在微前端中比较常见,可以在内部定义任何样式也不会污染外部的样式,但是也因为这个特征导致 Shadow DOM 中也不会继承任何外部样式。假如你使用 TailwindCSS 或者其他组件库自带的样式,在 Shadow DOM 中被应用。

例子

我们先来创建一个简单的 TailwindCSS 的单页应用。

// Create a shadow DOM tree and define a custom element
class MyCustomElement extends HTMLElement {
  constructor() {
    super()
    // Attach a shadow DOM to the custom element
    const shadow = this.attachShadow({ mode: 'open' })

    // Create some content for the shadow DOM
    const wrapper = document.createElement('div')
    wrapper.setAttribute('class', 'text-2xl bg-primary text-white p-4')
    wrapper.textContent = 'I am in the Shadow DOM tree.'

    // Append the content to the shadow DOM
    shadow.append(wrapper)
  }
}

// Define the custom element
customElements.define('my-custom-element', MyCustomElement)

export const Component = () => {
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        I'm in the Root DOM tree.
      </p>
      <my-custom-element />
    </>
  )
}

上面的代码运行结果如下:

上面一个元素位于 Host(Root) DOM 中,TailwindCSS 的样式正确应用,但是在 ShadowRoot 中的元素无法应用样式,仍然是浏览器的默认样式。

方案

我们知道打包器会把 CSS 样式注入到 document.head 中,那么我们只要把这些标签提取出来同样注入到 ShadowRoot 中去就行了。

那么如何实现呢。

以 React 为例,其他框架也是同理。

在 React 中使用 Shadow DOM 可以借助 react-shadow 以提升 DX。

npm i react-shadow

上面的代码可以修改为:

import root from 'react-shadow'

export const Component = () => {
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        I'm in the Root DOM tree.
      </p>
      <root.div>
        <p className="text-2xl bg-primary text-white p-4">
          I'm in the Shadow DOM tree.
        </p>
      </root.div>
    </>
  )
}

现在依然是没有样式的,接着我们注入宿主样式。

import type { ReactNode } from 'react'
import { createElement, useState } from 'react'
import root from 'react-shadow'

const cloneStylesElement = () => {
  const $styles = document.head.querySelectorAll('style').values()
  const reactNodes = [] as ReactNode[]
  let i = 0
  for (const style of $styles) {
    const key = `style-${i++}`
    reactNodes.push(
      createElement('style', {
        key,
        dangerouslySetInnerHTML: { __html: style.innerHTML },
      }),
    )
  }

  return reactNodes
}
export const Component = () => {
  const [stylesElements] = useState<ReactNode[]>(cloneStylesElement)
  return (
    <>
      <p className="text-2xl bg-primary text-white p-4">
        I'm in the Root DOM tree.
      </p>
      <root.div>
        <head>{stylesElements}</head>
        <p className="text-2xl bg-primary text-white p-4">
          I'm in the Shadow DOM tree.
        </p>
      </root.div>
    </>
  )
}

现在样式就成功注入了。可以看到 ShadowDOM 中已经继承了宿主的样式。

宿主样式响应式更新

现在的方式注入样式,如果宿主的样式发生了改变,ShadowDOM 的样式并不会发生任何更新。

比如我加了一个 Button,点击后新增一个样式。

<button
  className="btn btn-primary mt-12"
  onClick={() => {
    const $style = document.createElement('style')
    $style.innerHTML = `p { color: red !important; }`
    document.head.append($style)
  }}
>
  Update Host Styles
</button>

可以看到 ShadowDOM 没有样式更新。

我们可以利用 MutationObserver 去观察 <head /> 的更新。

export const Component = () => {
  useLayoutEffect(() => {
    const mutationObserver = new MutationObserver(() => {
      setStylesElements(cloneStylesElement())
    })
    mutationObserver.observe(document.head, {
      childList: true,
      subtree: true,
    })

    return () => {
      mutationObserver.disconnect()
    }
  }, [])

  // ..
}

效果如下:

生产环境中的问题

上面的例子中,我们只对 <style /> 做了处理,一般在开发环境中,CSS 都是使用动态注入 <style /> 的,而在生产环境中大部分的 CSS 都会编译成静态的 CSS 文件,使用 <link rel="stylesheet" /> 的方式注入。

当我们把上面的代码稍作修改之后:

const cloneStylesElement = () => {
  const $styles = document.head.querySelectorAll('style').values()
  const reactNodes = [] as ReactNode[]
  let i = 0
  for (const style of $styles) {
    const key = `style-${i++}`
    reactNodes.push(
      createElement('style', {
        key,
        dangerouslySetInnerHTML: { __html: style.innerHTML },
      }),
    )
  }

  document.head.querySelectorAll('link[rel=stylesheet]').forEach((link) => {
    const key = `link-${i++}`
    reactNodes.push(
      createElement('link', {
        key,
        rel: 'stylesheet',
        href: link.getAttribute('href'),
        crossOrigin: link.getAttribute('crossorigin'),
      }),
    )
  })

  return reactNodes
}

发现可以正常注入了,但是又出现了样式异步加载导致的布局变动。

这是因为每次使用 <ShadowDOM /> 都会创建一个新的 <link /> 而 link 标签会去异步加载 CSS 样式,导致在刚开始的时候样式没有载入显示的是浏览器默认的样式,导致出现布局和样式抖动。

解决这个办法我们必须改变方式,不再使用 <link /> 的注入,而是使用 <style />

通过 document.styleSheets 这个 API,可以获取到当前的所有的生效或者没生效的 stylesheet。然后拿到里面的 cssText

方法如下:

const cacheCssTextMap = {} as Record<string, string>

function getLinkedStaticStyleSheets() {
  const $links = document.head
    .querySelectorAll('link[rel=stylesheet]')
    .values() as unknown as HTMLLinkElement[]

  const styleSheetMap = new WeakMap<
    Element | ProcessingInstruction,
    CSSStyleSheet
  >()

  const cssArray = [] as { cssText: string; ref: HTMLLinkElement }[]

  for (const sheet of document.styleSheets) {
    if (!sheet.href) continue
    if (!sheet.ownerNode) continue
    styleSheetMap.set(sheet.ownerNode, sheet)
  }

  for (const $link of $links) {
    const sheet = styleSheetMap.get($link)
    if (!sheet) continue
    if (!sheet.href) continue
    const hasCache = cacheCssTextMap[sheet.href]
    if (!hasCache) {
      if (!sheet.href) continue
      const rules = sheet.cssRules || sheet.rules
      let cssText = ''
      for (const rule of rules) {
        cssText += rule.cssText
      }

      cacheCssTextMap[sheet.href] = cssText
    }

    cssArray.push({
      cssText: cacheCssTextMap[sheet.href],
      ref: $link,
    })
  }

  return cssArray
}

这里为了后续的性能,还做了一下缓存,根据每个静态 CSS 文件的 Href 作为索引。

然后修改 cloneStylesElement 为:

const cloneStylesElement = () => {
  const $styles = document.head.querySelectorAll('style').values()
  const reactNodes = [] as ReactNode[]
  let i = 0
  for (const style of $styles) {
    const key = `style-${i++}`
    reactNodes.push(
      createElement('style', {
        key,
        dangerouslySetInnerHTML: { __html: style.innerHTML },
      }),
    )
  }

  getLinkedStaticStyleSheets().forEach(({ cssText }) => {
    const key = `link-${i++}`
    reactNodes.push(
      createElement('style', {
        key,
        dangerouslySetInnerHTML: { __html: cssText },
      }),
    )
  })

  return reactNodes
}

避免重渲染

直到这里,大部分的问题都已经解决了,但是如果你使用 React 的话,还需要考虑重渲染问题,<style /> 的重渲染可能会导致布局抖动。

我们知道 React 组件的 key 可以决定组件的卸载周期,而 props 可以决定组件的重渲染。

恒定 Key

在上面的例子中,我们使用 style-${i++} 索引去做 Key,后续很有可能导致索引变化而组件被重建。因此我们需要一个更加稳定的 Key。

我们可以根据 ownerNode 去决定 Key。ownerNode 是对 HTMLLinkElement | HTMLStyleElement 的引用,因此只要该样式存在就是恒定的。

const weakMapElementKey = new WeakMap<
  HTMLStyleElement | HTMLLinkElement,
  string
>()

/// ....

let key = weakMapElementKey.get($style)

if (!key) {
  key = nanoid(8)

  weakMapElementKey.set($style, key)
}

reactNodes.push(
  createElement('style', {
    key,
    dangerouslySetInnerHTML: { __html: cssText },
  }),
)

恒定 props

在 React 中,如果你使用 dangerouslySetInnerHTML 去设置 HTML,那么它本身就是不稳定的 props。我们知道 dangerouslySetInnerHTML={{ __html: '' }} 传入的是一个不稳定的对象。在上面的例子中也是如此。

因此我们需要创建一个稳定的 MemoedDangerousHTMLStyle 组件。

const MemoedDangerousHTMLStyle: FC<
  {
    children: string
  } & React.DetailedHTMLProps<
    React.StyleHTMLAttributes<HTMLStyleElement>,
    HTMLStyleElement
  > &
  Record<string, unknown>
> = memo(({ children, ...rest }) => (
  <style
    {...rest}
    dangerouslySetInnerHTML={useMemo(
      () => ({
        __html: children,
      }),
      [children],
    )}
  />
))

直到这里,大部分工作就结束了。

完成代码参考:

https://github.com/RSSNext/Follow/blob/c1e3a025f7ef8ff570399a44a95797c2c8261e42/src/renderer/src/components/common/ShadowDOM.tsx

后记

既然是这样,那么你为什么还要用 ShadowDOM 呢。因为在 ShadowDOM 你可以注入任何污染全局的样式都不会影响宿主的样式。

这个方案其实很简单,在任何框架中甚至原生都是适用的,这个本身就是一个 Vanilla JS 的解决方案,不依赖任何框架。

而我只想说的是,不要被现代前端各式各样的工具链,插件让思维禁锢了,遇到一点点问题就想从框架出发或者插件,殊不知这只是个普通的 DOM 操作而已,所以就有了笑话,现在的前端开发连写个 jQuery 的 DOM 遍历都不知道了。

看完了?说点什么呢

再渡轮回

2024-08-25 19:46:23

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/178

经过了五天的努力,我已经通关了「黑神话 · 悟空」。这是一款既塞尔达传说之后让我如此沉迷的。每天梦里都有那棍法。

上回说到,我在 PS5 上玩到虎先锋就一直过不去。于是我在 Steam 平台购买了,使用风灵月影来降低游戏的难度。按我的游戏水平,将无限生命和满暴击点上之后就能愉快地玩耍了。然后点上三倍的移速。目前玩了 20 小时,一周目通关。也打了每个地图的隐藏地图,打了二郎神,结局是没带金箍。

https://innei.in/notes/177

在自己还没有通关之间,就一直刷别人的速通结局,想看看除了带金箍的坏结局和不带金箍的结局之外还有什么隐藏结局,相信也是很多人对现在的结局是不太满意的。当我自己通关之后,突然又觉得有点合情合理。游戏是玩家扮演天命人去寻找大圣的六根,但是老猴子最后说“意”的一根没了,但是通过打完二郎神之后浮现的记忆不就是所谓的“意”。那么集齐了六根不就是现代的大圣。反之,如果没打二郎神,那么就不会有这个“意”,最后还是会带上金箍再次走上轮回。这么想似乎也合情合理。

在花果山换上大圣套之后,装备上如意金箍棒,打出大圣棍势,真的好像成为了大圣。

(此处没有图,忘截了,已开启二周目)

不得不说,这个游戏的美术太棒了,细节做的也非常到位,比如猪八戒和天命人在不同场景会有不一样的对话,甚至在花果山中还会教你怎么打,比如如果你棍势不够没能破招他会说“棍势低了”,定身术的时候,如果时机不合适他会说“这就定了?”如果刚好敌人出招时定就会说“定的好啊”。此类的彩蛋对话还有很多比如面对二郎神时候变身。

一周目的我唯唯诺诺,然而二周目,即便没用修改器,在一周目虐我千百遍的大头娃娃也是随便过,一个三豆蓄力+分身之后打半血。当然妖怪的坦度和伤害都是比一周目要高得多。

看完了?说点什么呢

迷失在黄风岭

2024-08-21 17:17:14

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/177

此文章没有任何图片和剧透请放心阅读。

昨天是 黑神话悟空 的发售日,我也是和众人一样,早早的就醒了,等待 10 点的到来。

在此之前,我并没有太多的期待,我一向是手残党,也玩不来魂游,不是受虐体质。但是随着越来越多的宣传片和试玩放出,让我也蠢蠢欲试。嫖到了朋友的 PS5 共享,我也在前两天完成了预载。10 点一到,游戏进入。

也没有想到国产有天也能做出这样的 3A 游戏,而不是一款只要氪金就能变强的网游。这画质确实很好。玩法上,虽然说是 ARPG 游戏,但是难度还是挺大的,而且无法调难度确实是有魂游那个味。个人玩起来的感受是,第一章还是挺容易的,虽然大头和尚那里卡了半个钟多,当然这些前期也可以不打选择逃课。从和之前玩只狼的体验来说,还是相对简单的。第一章这里,我死的最多的地方应该就是大头和尚了,打黑熊精都没死这么多次。

到了第二章,地图就比较大了。黄风岭这里没有太多的引导,地图又大还没有地图,很多时候都不知道应该去哪,扫了一波小怪之后发现根本没路可走;进入一个地方之后凭直觉往下走的确实死路但是也不知道是死路,然后就一直被地图玩弄。光是走迷宫就花了不少时间。谁说是线性游戏,但是到了这里又有点类开放世界,却又没有地图。很多传送点土地庙甚至可以不点(其实是不知道就跳关了),很多关键 Boss 也跳过了,完全不知道剧情走向,很是疑惑。难度也提升了,我倒现在还卡在虎先锋。这玩意要把我打奔溃了。

总体来说,作为第一款 3A 能做到这样还算是不错,但是也不至于满分这么高,很多地方体验还是非常不好。比如没有地图,经常按不出技能不知道是 bug 还是特意设计(在必须在角色 idle 时候对按键才有反应),蓄力没有反馈不知道有没有在进行只能看条等等。

看完了?说点什么呢

从讨厌写作到乐于分享:我与写博客的心路历程

2024-08-05 21:56:40

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/176

另外我更觉得写博客这种事... 写 比 发 更重要。

当你有一箩筐精心打磨的内容无论到哪个平台都应该是会受到关注的,而且既然此人都能打磨出高质量的内容他必然也是领域精英,也一定可以收获社媒声量。

所以不用担心没人看,我们都该担心自己愿不愿意写。

今天的主题从川哥的一个推文讲起。

截止今日,我也已经写了 298 篇文章了。从 2018 年我开始读大学开始,一直到现在。在这里时间线你还能看到每一篇曾经的我写过的,包括黑历史。

曾经,我是非常讨厌写作的。还想起以前读初中的时候,语文老师每周都要求我们写周记,每两周还会布置作文,对于我这样一个平时生活枯燥无味,没有任何经历和故事的人,要写这些真是太难了,根本无法下手,只能靠编,而我又是一个不擅长撒谎的人。每每这个时候也是我最痛苦的时候。

我从来不觉得写作是一件快乐的事,直到有一天我接触到了博客,这个想法才有所改变。我不喜欢的只是别人强迫你需要的写的,而从来不是发自内心的。写博客,是发自内心的,没有人的逼迫的,是想写什么就写什么的,我可以写下一遍技术的文章,不管写的好坏,至少是我经历过的,当然如果能够得到读者的称赞,那也是每个作者感到庆幸的。

或许有一段时间内,我会产出比较多的文章, 再或者有很长的时间我没有任何产出,我不必为此感到焦虑。这不是任务,只是一种乐于分享的精神。

当初为了记录生活,我专门为此编写一个独立 CMS 去管理,直到今天的「手记」。虽然其中的很多内容并不是记录生活(笑),而是表达内心的焦虑、担忧、绝望的心境,那也是我表达情感的一种方式,但是能够得到一句陌生人的支持和鼓励,心里也会感到一股暖流。

其实我是一个不擅长记录的人,很多时候,都是过了一段时间之后,回想起来,哦,这段时间好像发生了什么,我得写点什么,然后脑海里过一遍,开始一点一点写下来。这篇文章也是,没有任何计划,只是在路边散步一会,哦,我得写这些东西,回来之后就开始写了。

我不像很多写周记的人那样会记录每天干了什么,发生了什么有趣的事,然后在周记发布的前几小时,整理和排版,而我总是在发布的前一个小时,完成写作然后取个题目,发布。

看完了?说点什么呢

忙碌中的思索:生活、工作与娱乐

2024-07-24 00:00:39

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/175

已经有一个月没有写点什么了,但是好像也无话可写。

行业交流

最近这段时间,开始忙起来了。除了主业之外,也尝试开始发展一些副业。很快,新工作也已经两个月了,内心也还是有些焦虑的,也不知道之后试用期能不能过。

这些天和行业内的老人也算是进行了一些交流,大环境真的挺差的,所以还是需要一些其他的收入去缓解焦虑。聊到大厂,只有带来了岁月的蹉跎,却没有获得在真正需要帮助的时候所谓的人脉,厂里的人脉并没有能够在你失业的时候拉你一把。既没有技术的长进,也受限于圈子认识不到更多的人。结合我之前的经历,确实也感觉是这样,厂里虽然人多,但真正有实力的人少之又少,也很少有机会能够接触认识。而恰恰在创业圈,在初创公司之间却能认识更多独立开发者和业界大佬。

旧友新愁

前些天去了躺上海,和一些前同事出来见了见,唠唠嗑。离开小红书也就一年多的时间,而我当时所在的那个组,已经彻底翻天覆地了,已经没有多少老人了。自从阿里空降的 Ld,慢慢的充满了阿里味。有前同事表示也想找个远程工作了,组里氛围太差,工作太卷了。每天一群人死撑着不下班,也不知道是为了什么。也许是都知道现在工作不好找,加上裁员的消息,都不希望在这个时间被裁员吧。很多人都有家庭,房贷,生活的压力太大了。现在就是这样一个病态社会,连空气中都弥漫着不安。

Follow 开发

最近这个月,还是在全力开发 Follow 中,修改了好多细节,也新增了不少的新功能,马上就要开放内测了,大家觉得有什么 RSS Reader 需要的,重要的功能都可以留言告诉我。

https://github.com/RSSNext/follow

这周在公司内部,有个分享,我打算讲讲 Follow 这个项目中的数据流,以及未来前端数据管理是怎么样的。


电影观影

前不久,久违的又独自去看了场电影。沈腾、马丽的「抓娃娃」。被称为西虹市首富第二部,巨好笑,几乎整场电影都爆笑得停不下来。

喜剧的内核确实悲剧,富人家的孩子从小生活在楚门的世界中,一切都是被安排好的,不能有自己的梦想,只能任由被他人操控自己的人生。

https://www.themoviedb.org/movie/1299537


还刷了国产的一部网剧。

https://www.themoviedb.org/tv/258924

动漫盛宴

最近挺多番可以看的。

不知道四月番更新了鬼灭之刃柱训练篇,所以最近一口气刷完了。

其他的还有:

  • 不时用俄语小声说真心话的邻桌艾莉同学

https://www.themoviedb.org/tv/235758

  • 地下城里的人们

https://www.themoviedb.org/tv/250597

  • 无职转生Ⅱ ~到了异世界就拿出真本事~ 第2部分

https://www.themoviedb.org/tv/94664

  • 【我推的孩子】 第二季

https://www.themoviedb.org/tv/203737

看完了?说点什么呢

在 Nest.js 中使用 Auth.js

2024-07-14 14:45:25

当前内容无法在 RSS render 中正确渲染,请前往:https://innei.in/posts/tech/using-auth-js-in-nestjs

一种在 Electron 和 Web 环境下显示原生及自定义菜单的通用方法

2024-06-27 15:00:43

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/a-universal-method-about-show-electron-native-and-web-custom-menus

本文介绍一种可以在 Electron 应用中显示原生菜单,并且在非 Electron 环境(Web)下也可以显示自定义的上下文菜单的方法。通过封装一个通用组件和调用方法,在两套环境中交互统一。

调出原生菜单

在 Electron 中,默认情况下右键并不会弹出类似 Chrome 中的上下文菜单。很多时候我们需要根据自己的业务场景编写相应的上下文菜单。

我们可以使用 Menu 去构建一个原生的上下文菜单。在主进程中通过 ipcMain 监听事件,通过 Menu.buildFromTemplate 然后 popup 方法显示原生菜单。

ipcMain.on('show-context-menu', (event) => {
  const template = [
    {
      label: 'Menu Item 1',
      click: () => {
        console.log('Menu Item 1 clicked')
      },
    },
    {
      label: 'Menu Item 2',
      click: () => {
        console.log('Menu Item 2 clicked')
      },
    },
  ]
  const menu = Menu.buildFromTemplate(template)
  menu.popup(BrowserWindow.fromWebContents(event.sender))
})

在 Render 进程中我们可以通过 ipcRenderer.send() 发送指定的事件打开菜单。

const ipcHandle = (): void => window.electron.ipcRenderer.send('show-context-menu')
<button onContextMenu={ipcHandle}>
 Right click to open menu
</button>

效果如下图所示。

绑定点击事件

上面的实现中,我们的菜单是写死的,而且点击事件都在 Main 进程中被执行,而很多时候,我们需要在 Render 进程中执行菜单的点击事件。因此我们需要实现一个动态的菜单构造方法。

现在我们来实现这个方法,我们使用 @egoist/tipc 定义一个类型安全的桥方法。

export const router = {
  showContextMenu: t.procedure
    .input<{
      items: Array<
        | { type: 'text'; label: string; enabled?: boolean }
        | { type: 'separator' }
      >
    }>()
    .action(async ({ input, context }) => {
      const menu = Menu.buildFromTemplate(
        input.items.map((item, index) => {
          if (item.type === 'separator') {
            return {
              type: 'separator' as const,
            }
          }
          return {
            label: item.label,
            enabled: item.enabled ?? true,
            click() {
              context.sender.send('menu-click', index)
            },
          }
        }),
      )

      menu.popup({
        callback: () => {
          context.sender.send('menu-closed')
        },
      })
    }),
}

这里我们定义了两个事件,一个用来发送点击菜单 item 时候发送,另一个则是菜单被关闭。这个方法在 Main 进程中执行,所以这里的事件接收方都是 Render 进程。那么在 menu-click 事件发送到 Render 进程后根据 index 执行相应的方法。

在 Render 进程中,定义一个调用菜单的方法。和 Main 进程通过桥通信。在接受到 Main 进程的 menu-click 事件之后,在 Render 进程中执行方法。

export type NativeMenuItem =
  | {
      type: 'text'
      label: string
      click?: () => void
      enabled?: boolean
    }
  | { type: 'separator' }
export const showNativeMenu = async (
  items: Array<Nullable<NativeMenuItem | false>>,
  e?: MouseEvent | React.MouseEvent,
) => {
  const nextItems = [...items].filter(Boolean) as NativeMenuItem[]

  const el = e && e.currentTarget

  if (el instanceof HTMLElement) {
    el.dataset.contextMenuOpen = 'true'
  }

  const cleanup = window.electron?.ipcRenderer.on('menu-click', (_, index) => {
    const item = nextItems[index]
    if (item && item.type === 'text') {
      item.click?.()
    }
  })

  window.electron?.ipcRenderer.once('menu-closed', () => {
    cleanup?.()
    if (el instanceof HTMLElement) {
      delete el.dataset.contextMenuOpen
    }
  })

  await tipcClient?.showContextMenu({
    items: nextItems.map((item) => {
      if (item.type === 'text') {
        return {
          ...item,
          enabled: item.enabled ?? item.click !== undefined,
          click: undefined,
        }
      }

      return item
    }),
  })
}

一个简单的使用方式如下:

<div
  onContextMenu={(e) => {
    showNativeMenu(
      [
        {
          type: 'text',
          label: 'Rename Category',
          click: () => {
            present({
              title: 'Rename Category',
              content: ({ dismiss }) => (
                <CategoryRenameContent
                  feedIdList={feedIdList}
                  category={data.name}
                  view={view}
                  onSuccess={dismiss}
                />
              ),
            })
          },
        },
        {
          type: 'text',
          label: 'Delete Category',

          click: async () => {
            present({
              title: `Delete category ${data.name}?`,
              content: () => (
                <CategoryRemoveDialogContent feedIdList={feedIdList} />
              ),
            })
          },
        },
      ],
      e,
    )
  }}
></div>

在 Web 中显示自定义上下文菜单

上面的实现中,在 Electron 环境中显示业务自定义的上下文菜单。但是在 Web app 中,无法显示,取而代之的是 Chrome 或者其他浏览器提供的菜单。这样会导致交互不统一并且有关右键菜单的很多操作都无法实现。

这一节我们利用 radix/context-menu 实现一个上下文菜单的 UI,并且对上面的 showNativeMenu 进行改造,使得这个方法在两个环境中有相同的交互逻辑,那么这样的话,我们就不必修改业务代码,而是在 showContextMenu 中进行抹平。

首先安装 Radix 组件:

ni @radix-ui/react-context-menu

然后可以复制 shadcn/ui 的样式,微调 UI。

在 App 顶层定义一个全局的上下文菜单 Provider。代码如下:

export const ContextMenuProvider: Component = ({ children }) => (
  <>
    {children}
    <Handler />
  </>
)

const Handler = () => {
  const ref = useRef<HTMLSpanElement>(null)

  const [node, setNode] = useState([] as ReactNode[] | ReactNode)
  useEffect(() => {
    const fakeElement = ref.current
    if (!fakeElement) return
    const handler = (e: unknown) => {
      const bizEvent = e as {
        detail?: {
          items: NativeMenuItem[]
          x: number
          y: number
        }
      }
      if (!bizEvent.detail) return

      if (
        !('items' in bizEvent.detail) ||
        !('x' in bizEvent.detail) ||
        !('y' in bizEvent.detail)
      ) {
        return
      }
      if (!Array.isArray(bizEvent.detail?.items)) return

      setNode(
        bizEvent.detail.items.map((item, index) => {
          switch (item.type) {
            case 'separator': {
              return <ContextMenuSeparator key={index} />
            }
            case 'text': {
              return (
                <ContextMenuItem
                  key={item.label}
                  disabled={item.enabled === false || item.click === undefined}
                  onClick={() => {
                    // Here we need to delay one frame,
                    // so it's two raf's, in order to have `point-event: none` recorded by RadixOverlay after modal is invoked in a certain scenario,
                    // and the page freezes after modal is turned off.
                    nextFrame(() => {
                      item.click?.()
                    })
                  }}
                >
                  {item.label}
                </ContextMenuItem>
              )
            }
            default: {
              return null
            }
          }
        }),
      )

      fakeElement.dispatchEvent(
        new MouseEvent('contextmenu', {
          bubbles: true,
          cancelable: true,
          clientX: bizEvent.detail.x,
          clientY: bizEvent.detail.y,
        }),
      )
    }

    document.addEventListener(CONTEXT_MENU_SHOW_EVENT_KEY, handler)
    return () => {
      document.removeEventListener(CONTEXT_MENU_SHOW_EVENT_KEY, handler)
    }
  }, [])

  return (
    <ContextMenu>
      <ContextMenuTrigger className="hidden" ref={ref} />
      <ContextMenuContent>{node}</ContextMenuContent>
    </ContextMenu>
  )
}

CONTEXT_MENU_SHOW_EVENT_KEY 定义一个事件订阅的 Key,在 showNativeMenu 时,将被发送。转而被顶层 ContextMenuProvider 监听,通过 new MouseEvent("contextmenu") 模拟一个右键操作,设定当前的上下文菜单 Item。

在 App 顶层挂载:

export const App = () => {
  return <ContextMenuProvider>
  {...}
  </ContextMenuProvider>
}

改造一下 showNativeMenu 方法:

import { tipcClient } from './client'

export type NativeMenuItem =
  | {
      type: 'text'
      label: string
      click?: () => void
      enabled?: boolean
    }
  | { type: 'separator' }
export const showNativeMenu = async (
  items: Array<Nullable<NativeMenuItem | false>>,
  e?: MouseEvent | React.MouseEvent,
) => {
  const nextItems = [...items].filter(Boolean) as NativeMenuItem[]

  const el = e && e.currentTarget

  if (el instanceof HTMLElement) {
    el.dataset.contextMenuOpen = 'true'
  }

  if (!window.electron) {
    document.dispatchEvent(
      new CustomEvent(CONTEXT_MENU_SHOW_EVENT_KEY, {
        detail: {
          items: nextItems,
          x: e?.clientX,
          y: e?.clientY,
        },
      }),
    )
    return
  }

  const cleanup = window.electron?.ipcRenderer.on('menu-click', (_, index) => {
    const item = nextItems[index]
    if (item && item.type === 'text') {
      item.click?.()
    }
  })

  window.electron?.ipcRenderer.once('menu-closed', () => {
    cleanup?.()
    if (el instanceof HTMLElement) {
      delete el.dataset.contextMenuOpen
    }
  })

  await tipcClient?.showContextMenu({
    items: nextItems.map((item) => {
      if (item.type === 'text') {
        return {
          ...item,
          enabled: item.enabled ?? item.click !== undefined,
          click: undefined,
        }
      }

      return item
    }),
  })
}

export const CONTEXT_MENU_SHOW_EVENT_KEY = 'contextmenu-show'

判断非 Electron 环境下,发送事件被 Provider 监听,而且显示上下文菜单。

效果如下:

参考

https://github.com/RSSNext/follow/blob/2ff6fc008294a63c71b0ecc901edf1ea8948d37c/src/renderer/src/lib/native-menu.ts

https://github.com/RSSNext/follow/blob/800706a400cefcf4f379a9bbc7e75f540083fe6b/src/renderer/src/providers/context-menu-provider.tsx

看完了?说点什么呢

被裁员后的恐惧

2024-06-22 12:37:18

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/174

最近一直都在被裁员之后的恐惧中。

不知不觉已经过去了一个多月了。

上个月的 9 号下午,通知我被优化了,没有任何的征兆,原因是项目组资金砍半了,人也要砍半。想起来也是伤感,自从离开小红书后,选择降薪去到一家喜欢的公司做充满艺术感的产品,本以为能够一直做下去,同时和大家一起做一些好玩的产品,而最后我只是一个弃子。很不幸,才干了一年的时间就被优化了。一时间不知所措,急性焦虑发作,整整心慌了两天。也许是人生第一次被裁员,虽然原因并不在我,但是还是收到了莫大的打击,感觉我是被抛弃的一个。直到现在我还在裁员的阴影中走不出来,而现在我已经入职了新的公司,我一样担忧着裁员的风险。即使是一次裁员就让我如此痛苦,不敢想到如果是失恋那会是怎样,这也是我一直不敢恋爱的一个原因吧。一向悲观的我,从不对自己的未来充满信心。

入职新公司已经满三周了,这三周里,我几乎 all in 目前在做的产品了,当然它是开源的,我们要做一款最好用的 RSS 阅读器。欢迎大家支持。近期,我也会抽时间总结一些技术方面的细节。另外,最近我一直在探索 web app local-first 的解决方案,希望有朝一日能够在一个产品中落地。

https://github.com/RSSNext/follow

看完了?说点什么呢

不敢改变是我在焦虑什么

2024-06-09 13:15:17

该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/173

事业

当下的大环境确实不容乐观,就业形势严峻,不稳定的局面让未来变得非常不确定。这种情况下,我发现自己在面对需要长期投入的事情时,常常感到焦虑和担忧,害怕计划被破坏后不知道该怎么办,最终不敢去改变现状。实际上,这种不敢改变的心理反映了我内心深处对未来的恐惧以及对自己能力的怀疑。

未来的不确定性让我对自己的计划充满了疑虑。我常常问自己:现在做这个决定是否明智?如果未来环境变得更糟糕,我是否还有能力坚持下去?这些疑虑让我在面对重大决策时,往往选择保守和稳定,而不是冒险和改变。因为我害怕一旦计划被打乱,自己将无法应对突如其来的变化,最终导致失败。

在面对需要长期投入的事情时,我往往会对自己的能力产生怀疑。例如,学习一门技能(如钢琴)或者谈一次恋爱,我会担心自己是否有足够的能力去完成这些事情,是否能在长时间内坚持下去。当事业不稳定时,我的心态容易崩溃,这种状态进一步加剧了我的焦虑和不安,使我在工作之外的长期投入的事情上更加犹豫不决,最终因为恐惧和不安而放弃。

我确实会担心理财方面我做的不够好,担心自己没有可持续的收入来源,担心自己有一天会因为钱不能勇敢的做出选择,担心未来会因为一些现在还无法预料的事情“老大徒伤悲”,担心自己垂垂老矣之日没有足够的积蓄支付医疗费用……但是这些更进一步的追问与反思依然不能回答我到底需要多少钱,或者我到底需要保证怎样的增长率,这些问题只能使我更迷茫。

于是我陷入了怀疑,是不是因为缺少人生的规划能力和全局观使我迷茫。我喜欢充满不确定性的人生,完全没有明确清晰的如升职跳槽加薪等等这些人生规划,并不觉得自己真的能寿终正寝,无法替我导演出未来的剧本。所以可能隐隐约约另一方面担心万一我真的寿终正寝了但是没有钱为自己养老怎么办,担心是否有一天的时候因为金钱能力不足无法满足自己逃离游戏规则的任性。

引用:当我在为钱焦虑的时候我在焦虑什么

看完了?说点什么呢