普通视图

发现新文章,点击刷新页面。
今天 — 2025年5月9日静かな森

使用 React Native Screens 构建一个 Simple Navigation

2025年5月9日 01:01
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/build-simple-navigation-with-react-native-screens

上回说到,我们已经大概了解了 React Native Screen 内部是如何工作的。这篇文章将综合前面的内容,实现一个简单的 React Native Navigation。

构想

一个 Navigation 最基础的就是要实现一个 navigate 方法。

navigate 方法需要实现前进后退的基本行为。

模拟在 iOS Native 中,我们可以使用 pushController 和 presentController 两个方法去实现前进的行为。

那么我们可以命名为:

  • pushControllerView 推入 Stack Navigation
  • presentControllerView 推入 Modal

然后是后退的行为,命名为:

  • back 回退 Stack Navigation
  • dismiss 关闭 Modal

然后我们需要集中式管理 push 和 present 的 navigation 的数据,然后在 UI 中呈现和反馈。

实现

Navigation 框架

顺着上面的构想,我们首先需要一个 Navigation 的类。

class Navigation {}

我们需要把 navigation 的数据保存在这个类中,所以我还需要定义数据的类型。

export interface Route {
  id: string

  Component?: NavigationControllerView<any>
  element?: React.ReactElement

  type: NavigationControllerViewType
  props?: unknown
  screenOptions?: NavigationControllerViewExtraProps
}

export type NavigationControllerViewExtraProps = {
  /**
   * Unique identifier for the view.
   */
  id?: string

  /**
   * Title for the view.
   */
  title?: string

  /**
   * Whether the view is transparent.
   */
  transparent?: boolean
} & Pick<
  ScreenProps,
  | 'sheetAllowedDetents'
  | 'sheetCornerRadius'
  | 'sheetExpandsWhenScrolledToEdge'
  | 'sheetElevation'
  | 'sheetGrabberVisible'
  | 'sheetInitialDetentIndex'
  | 'sheetLargestUndimmedDetentIndex'
>

export type NavigationControllerView<P = {}> = FC<P> &
  NavigationControllerViewExtraProps

上面我们定义 NavigationControllerView 的类型,和 Route 的类型。NavigationControllerView 用于定义 NavigationView 的组件类型,Route 用于在 Navigation 类中保存 navigation 的数据。

为了实现在 UI 中的响应式,我们使用 Jotai 去管理这个数据。

export type ChainNavigationContextType = {
  routesAtom: PrimitiveAtom<Route[]>
}

在 Navigation 类中初始化数据:

export class Navigation {
  private ctxValue: ChainNavigationContextType
  constructor(ctxValue: ChainNavigationContextType) {
    this.ctxValue = ctxValue
  }

  static readonly rootNavigation: Navigation = new Navigation({
    routesAtom: atom<Route[]>([]),
  })
}

Navigation 数据管理

上面已经定义了 Navigation 的类型,然后我们通过对数据的控制来实现 push/back 的操作。

class Navigation {
  private viewIdCounter = 0
  private __push(route: Route) {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const hasRoute = routes.some((r) => r.id === route.id)
    if (hasRoute && routes.at(-1)?.id === route.id) {
      console.warn(`Top of stack is already ${route.id}`)
      return
    } else if (hasRoute) {
      route.id = `${route.id}-${this.viewIdCounter++}`
    }
    jotaiStore.set(this.ctxValue.routesAtom, [...routes, route])
  }

  private resolveScreenOptions<T>(
    view: NavigationControllerView<T>,
  ): Required<NavigationControllerViewExtraProps> {
    return {
      transparent: view.transparent ?? false,
      id: view.id ?? view.name ?? `view-${this.viewIdCounter++}`,
      title: view.title ?? '',
      // Form Sheet
      sheetAllowedDetents: view.sheetAllowedDetents ?? 'fitToContents',
      sheetCornerRadius: view.sheetCornerRadius ?? 16,
      sheetExpandsWhenScrolledToEdge:
        view.sheetExpandsWhenScrolledToEdge ?? true,
      sheetElevation: view.sheetElevation ?? 24,
      sheetGrabberVisible: view.sheetGrabberVisible ?? true,
      sheetInitialDetentIndex: view.sheetInitialDetentIndex ?? 0,
      sheetLargestUndimmedDetentIndex:
        view.sheetLargestUndimmedDetentIndex ?? 'medium',
    }
  }

  pushControllerView<T>(view: NavigationControllerView<T>, props?: T) {
    const screenOptions = this.resolveScreenOptions(view)
    this.__push({
      id: screenOptions.id,
      type: 'push',
      Component: view,
      props,
      screenOptions,
    })
  }

  presentControllerView<T>(
    view: NavigationControllerView<T>,
    props?: T,
    type: Exclude<NavigationControllerViewType, 'push'> = 'modal',
  ) {
    const screenOptions = this.resolveScreenOptions(view)
    this.__push({
      id: screenOptions.id,
      type,
      Component: view,
      props,
      screenOptions,
    })
  }
}

之后,back 的操作也非常简单。

class Navigation {
  private __pop() {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const lastRoute = routes.at(-1)
    if (!lastRoute) {
      return
    }
    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, -1))
  }

  /**
   * Dismiss the current modal.
   */
  dismiss() {
    const routes = jotaiStore.get(this.ctxValue.routesAtom)
    const lastModalIndex = routes.findLastIndex((r) => r.type !== 'push')
    if (lastModalIndex === -1) {
      return
    }
    jotaiStore.set(this.ctxValue.routesAtom, routes.slice(0, lastModalIndex))
  }

  back() {
    return this.__pop()
  }
}

从上面的代码不难看出,其实我们只是通过对数据的操作实现 Navigation 的逻辑。而真正要在 UI 中呈现 Navigation 的效果还是需要通过 React Native Screens 来实现。

Navigation UI 框架

在上面的文章中,我们已经知道了我们只需要通过传入不同 React Children 到 React Native Screens 的 <ScreenStack /> 中就能实现原生的 navigate 的效果。

那我们现在只需要透过 Navigation 类中管理的数据,通过一些转换就能实现了。

首先我们在 React 中定义一个 Navigation 上下文对象,确保得到正确的 Navigation 实例(如有多个)。

export const NavigationInstanceContext = createContext<Navigation>(null!)

然后,编写一个 RootStackNavigation 组件。

import { SafeAreaProvider } from 'react-native-safe-area-context'
import type { ScreenStackHeaderConfigProps } from 'react-native-screens'
import { ScreenStack } from 'react-native-screens'

interface RootStackNavigationProps {
  children: React.ReactNode

  headerConfig?: ScreenStackHeaderConfigProps
}

export const RootStackNavigation = ({
  children,
  headerConfig,
}: RootStackNavigationProps) => {
  return (
    <SafeAreaProvider>
      <NavigationInstanceContext value={Navigation.rootNavigation}>
        <ScreenStack style={StyleSheet.absoluteFill}>
          <ScreenStackItem headerConfig={headerConfig} screenId="root">
            {children}
          </ScreenStackItem>
        </ScreenStack>
      </NavigationInstanceContext>
    </SafeAreaProvider>
  )
}

在 App 的入口文件中,我们使用 RootStackNavigation 组件包裹整个应用。

export default function App() {
  return (
    <RootStackNavigation>
       <HomeScreen>
    </RootStackNavigation>
  )
}

const HomeScreen = () => {
  return (
    <View>
      <Text>Home</Text>
    </View>
  )
}

RootStackNavigation 组件的 Children 为首屏,也是 Navigation 的根组件,不参与整体的 navigate 行为,即不能被 pop。

Navigation 数据在 UI 中呈现

接下来我们需要把这些数据转换到 React 元素传入到 React Native Screens 的 <ScreenStackItem /> 中。

const ScreenItemsMapper = () => {
  const chainCtxValue = use(ChainNavigationContext)
  const routes = useAtomValue(chainCtxValue.routesAtom)

  const routeGroups = useMemo(() => {
    const groups: Route[][] = []
    let currentGroup: Route[] = []

    routes.forEach((route, index) => {
      // Start a new group if this is the first route or if it's a modal (non-push)
      if (index === 0 || route.type !== 'push') {
        // Save the previous group if it's not empty
        if (currentGroup.length > 0) {
          groups.push(currentGroup)
        }
        // Start a new group with this route
        currentGroup = [route]
      } else {
        // Add to the current group if it's a push route
        currentGroup.push(route)
      }
    })

    // Add the last group if it's not empty
    if (currentGroup.length > 0) {
      groups.push(currentGroup)
    }

    return groups
  }, [routes])

  return (
    <GroupedNavigationRouteContext value={routeGroups}>
      {routeGroups.map((group) => {
        const isPushGroup = group.at(0)?.type === 'push'
        if (!isPushGroup) {
          return <ModalScreenStackItems key={group.at(0)?.id} routes={group} />
        }
        return <MapScreenStackItems key={group.at(0)?.id} routes={group} />
      })}
    </GroupedNavigationRouteContext>
  )
}

const MapScreenStackItems: FC<{
  routes: Route[]
}> = memo(({ routes }) => {
  return routes.map((route) => {
    return (
      <ScreenStackItem
        stackPresentation={'push'}
        key={route.id}
        screenId={route.id}
        screenOptions={route.screenOptions}
      >
        <ResolveView
          comp={route.Component}
          element={route.element}
          props={route.props}
        />
      </ScreenStackItem>
    )
  })
})

const ModalScreenStackItems: FC<{
  routes: Route[]
}> = memo(({ routes }) => {
  const rootModalRoute = routes.at(0)
  const modalScreenOptionsCtxValue = useMemo<
    PrimitiveAtom<ScreenOptionsContextType>
  >(() => atom({}), [])

  const modalScreenOptions = useAtomValue(modalScreenOptionsCtxValue)

  if (!rootModalRoute) {
    return null
  }
  const isFormSheet = rootModalRoute.type === 'formSheet'
  const isStackModal = !isFormSheet

  // Modal screens are always full screen on Android
  const isFullScreen =
    isAndroid ||
    (rootModalRoute.type !== 'modal' && rootModalRoute.type !== 'formSheet')

  if (isStackModal) {
    return (
      <ModalScreenItemOptionsContext value={modalScreenOptionsCtxValue}>
        <WrappedScreenItem
          stackPresentation={rootModalRoute?.type}
          key={rootModalRoute.id}
          screenId={rootModalRoute.id}
          screenOptions={rootModalRoute.screenOptions}
          {...modalScreenOptions}
        >
          <ModalSafeAreaInsetsContext hasTopInset={isFullScreen}>
            <ScreenStack style={StyleSheet.absoluteFill}>
              <WrappedScreenItem
                screenId={rootModalRoute.id}
                screenOptions={rootModalRoute.screenOptions}
              >
                <ResolveView
                  comp={rootModalRoute.Component}
                  element={rootModalRoute.element}
                  props={rootModalRoute.props}
                />
              </WrappedScreenItem>
              {routes.slice(1).map((route) => {
                return (
                  <WrappedScreenItem
                    stackPresentation={'push'}
                    key={route.id}
                    screenId={route.id}
                    screenOptions={route.screenOptions}
                  >
                    <ResolveView
                      comp={route.Component}
                      element={route.element}
                      props={route.props}
                    />
                  </WrappedScreenItem>
                )
              })}
            </ScreenStack>
          </ModalSafeAreaInsetsContext>
        </WrappedScreenItem>
      </ModalScreenItemOptionsContext>
    )
  }

  return routes.map((route) => {
    return (
      <ModalScreenItemOptionsContext
        value={modalScreenOptionsCtxValue}
        key={route.id}
      >
        <ModalSafeAreaInsetsContext hasTopInset={!isFormSheet}>
          <WrappedScreenItem
            screenId={route.id}
            stackPresentation={route.type}
            screenOptions={route.screenOptions}
          >
            <ResolveView
              comp={route.Component}
              element={route.element}
              props={route.props}
            />
          </WrappedScreenItem>
        </ModalSafeAreaInsetsContext>
      </ModalScreenItemOptionsContext>
    )
  })
})

const ResolveView: FC<{
  comp?: NavigationControllerView<any>
  element?: React.ReactElement
  props?: unknown
}> = ({ comp: Component, element, props }) => {
  if (Component && typeof Component === 'function') {
    return <Component {...(props as any)} />
  }
  if (element) {
    return element
  }
  throw new Error('No component or element provided')
}

const ModalSafeAreaInsetsContext: FC<{
  children: React.ReactNode
  hasTopInset?: boolean
}> = ({ children, hasTopInset = true }) => {
  const rootInsets = useSafeAreaInsets()
  const rootFrame = useSafeAreaFrame()

  return (
    <SafeAreaFrameContext value={rootFrame}>
      <SafeAreaInsetsContext
        value={useMemo(
          () => ({
            ...rootInsets,
            top: hasTopInset ? rootInsets.top : 0,
          }),
          [hasTopInset, rootInsets],
        )}
      >
        {children}
      </SafeAreaInsetsContext>
    </SafeAreaFrameContext>
  )
}

这里需要判断的逻辑可能会有点复杂,需要区分 Stack 和 Modal 的类型,在 ModalStack 中又需要区分 formSheet 等等。同时每个 Modal 中有需要再包裹一层 StackScreen 等等。

从简单来说,就是需要根据 Navigation 的数据,生成对应的 <ScreenStackItem />,然后传入到 <ScreenStack /> 中。

这里的详细的代码均可在下面的链接中查看:

https://github.com/RSSNext/Follow/blob/efc2e9713bcd54f82f9377de35ef5532008d6004/apps/mobile/src/lib/navigation/StackNavigation.tsx

然后我们还需要处理 native navigation 的状态同步,主要在 native 触发 pop 和 dismiss 的时机发送的事件。在前面的文章中讲过,可以通过 ScreenStackItemonDismissed 监听。

这里我们直接对 ScreenStackItem 再次封装。

export const WrappedScreenItem: FC<
  {
    screenId: string
    children: React.ReactNode
    stackPresentation?: StackPresentationTypes

    screenOptions?: NavigationControllerViewExtraProps
    style?: StyleProp<ViewStyle>
  } & ScreenOptionsContextType
> = memo(
  ({
    screenId,
    children,
    stackPresentation,

    screenOptions: screenOptionsProp,
    style,
    ...rest
  }) => {
    const navigation = useNavigation()

    const screenOptionsCtxValue = useMemo<
      PrimitiveAtom<ScreenOptionsContextType>
    >(() => atom({}), [])

    const screenOptionsFromCtx = useAtomValue(screenOptionsCtxValue)

    // Priority: Ctx > Define on Component

    const mergedScreenOptions = useMemo(
      () => ({
        ...screenOptionsProp,
        ...resolveScreenOptions(screenOptionsFromCtx),
      }),
      [screenOptionsFromCtx, screenOptionsProp],
    )

    const handleDismiss = useCallback(
      (
        e: NativeSyntheticEvent<{
          dismissCount: number
        }>,
      ) => {
        if (e.nativeEvent.dismissCount > 0) {
          for (let i = 0; i < e.nativeEvent.dismissCount; i++) {
            navigation.__internal_dismiss(screenId)
          }
        }
      },
      [navigation, screenId],
    )

    const ref = useRef<View>(null)

    return (
      <ScreenItemContext value={ctxValue}>
        <ScreenOptionsContext value={screenOptionsCtxValue}>
          <ScreenStackItem
            key={screenId}
            screenId={screenId}
            ref={ref}
            stackPresentation={stackPresentation}
            style={[StyleSheet.absoluteFill, style]}
            {...rest}
            {...mergedScreenOptions}
            onDismissed={handleDismiss}
            onNativeDismissCancelled={handleDismiss}
          >
            {children}
          </ScreenStackItem>
        </ScreenOptionsContext>
      </ScreenItemContext>
    )
  },
)

定义 NavigationControllerView

export const PlayerScreen: NavigationControllerView = () => {
  return <SheetScreen onClose={() => navigation.dismiss()}></SheetScreen>
}

PlayerScreen.transparent = true

使用 Navigation

那么现在我们就可以在 React 中使用 Navigation 了。

const navigation = useNavigation()

navigation.pushControllerView(PlayerScreen)

那么,一个简单的 Navigation 就完成了。

当然如果你有兴趣的话,也可以查看 Folo 这部分的完整实现,包括如何和 Bottom Tab 结合和页面 ScrollView 的联动。

https://github.com/RSSNext/Follow/blob/6694a346a0bd9f2cea19c71e87484acc56ed3705/apps/mobile/src/lib/navigation

看完了?说点什么呢

昨天以前静かな森

平淡中的小确幸

2025年4月24日 00:40
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/189

又是一个月过去了。

今年开始基本每个月只会写一篇手记了。生活中发生的事很少,表达欲也变淡了许多。

相聚片刻

自上个月以来,这个月没有去太多地方。借群友的路过,在我家住了一晚,也为了过了一次生日,一起吃了饭,那天我也没有太孤独,很是感动。第二天跟着一起去了上海,在上海和朋友们吃了一顿日料自助,解锁了新的吃法,因为不吃生海鲜,把甜虾放到寿喜锅煮熟了吃也是一种美味。一下子炫了有二十只。还有其他的串、板烧之类的,自己还是挺能吃的。但是减肥一直是一个大问题了。(本来先找找有没有照片,发现一张都没拍。)

而之后的日子,再没有出过城了。最远的地方只是去县城的郊区和曾经的高中同学看了一次花。起因是 '浙江“阿勒泰” - 小红书',但是照骗,去了也没找到在哪。

终于把很多人吹爆的「葬送的芙莉莲」第一季追完了,最近联名活动也比较多,只是可惜我居住的地方没有这类活动。人类生命短暂,错过机会会带来遗憾,活在当下,珍惜与亲人共度的时光,这些时刻将成为珍贵的回忆。

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

上个月说会免费试用 FSD 一个月,但是后面被叫停了。心心念念想体验一次,问问很多次都不能在店里试乘。终于最近有机会能去体验了一把。但是从我的试乘感受上来说,并没有网上说的那么神。首先是,一开始就翻车了,直接闯了红灯,在中间紧急接管刹车。然后又是走错车道,左转走了直行道。最后,在一条施工道路也走错了,跨越实线进入了非机动车道。整体乘坐体验还是挺舒适的,但是科目一确实不及格。

摄影初探

不知道什么时候开始突然有了一个想法,想买个相机了。虽然我一点都不会拍照。之前在上海的朋友用了下富士之后,这个念头更加强烈了。但是在 2025 年的今天想买一个 2020 年生产的机器居然还要溢价购买,或者线下店抽签或者线上点踩点蹲抢真是离了个大谱。又是过了段时间,找了淘宝上的授权店,加了一个 35定镜头,原价购买了 1650 套机。然后学学摄像。没事开车出去可以拍拍景。发几张个人觉得还行的。

:::gallery

:::

项目重启

工作上偶尔需要写写 Swift。空闲之余,我也在再一次入门 Apple 设备的 native 开发。而这次我选择重写两年前写的一个 macOS app - Process Reporter。这个 app 是用来上报当前我正在使用的 app 和 media 到 Mix Space 的。在我的网站上,呈现为:

两年前,我使用不成熟的 SwiftUI 糊了一个,全是 bug,功能也不太全面。而这次我选择用 AppKit 为主,SwiftUI 为辅去重写一个。经历了两周左右的马拉松和慢慢的打磨,现在功能基本差不多了。

这次加强了一下对 media process 的识别。现在可以准确的知道是哪个播放器在放歌了。(PS. macOS 在 15.4 及后续版本对私有接口加强了管制,导致后续版本无法正常识别任何 media 信息了)。另外加上了自动上传 app 的图标到图床的功能,没有预设的 app 也能正常显示图标。

最后,还有一个更好玩的。如果你使用 Slack 办公。那么就能实现这个效果。

非常简单的设置即可实现:

开源在:

https://github.com/Innei/ProcessReporter

看完了?说点什么呢

使用 React Native Screens 构建一个 Native Navigation 之内部原理

2025年4月16日 00:04
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-2

上回说到,我们已经初步了解 React Native Screens 的 ScreenStackItem 用法。这节我们探索一下这个组件的原生实现,然后写一个简单的 Navigation 类。

ScreenStackItem 是怎么实现的

源码定位

进入 React Native Screens 的源码,我们找到这个组件的位置。

我们看到这里使用了 Screen 组件。继续查找。

const Screen = React.forwardRef<View, ScreenProps>((props, ref) => {
  const ScreenWrapper = React.useContext(ScreenContext) || InnerScreen

  return <ScreenWrapper {...props} ref={ref} />
})

Screen 组件使用了 InnerScreen 组件。它是对 NativeView 的进一步封装。

用到了 AnimatedScreen,它针对不同场景使用的 Native 组件不同。但是最终都是一个 Screen 基类。

上面的 AnimatedNativeScreenAnimatedNativeModalScreen 都是实实在在的原生组件了。他们分别对应的是 RNSScreenRNSModalScreen 组件。

以 iOS 为例,找到其原生实现。通过 RSScreen.h 头文件看到,这个组件在原生中是一个 ViewController。所以它才会有这些生命周期事件。例如 viewDidLoadviewWillAppearviewDidAppear 等。


@interface RNSScreen : UIViewController <RNSViewControllerDelegate>

- (instancetype)initWithView:(UIView *)view;
- (UIViewController *)findChildVCForConfigAndTrait:(RNSWindowTrait)trait includingModals:(BOOL)includingModals;
- (BOOL)hasNestedStack;
- (void)calculateAndNotifyHeaderHeightChangeIsModal:(BOOL)isModal;
- (void)notifyFinishTransitioning;
- (RNSScreenView *)screenView;
#ifdef RCT_NEW_ARCH_ENABLED
- (void)setViewToSnapshot;
- (CGFloat)calculateHeaderHeightIsModal:(BOOL)isModal;
#endif

@end

RNSModalScreen 则是继承于 RNSScreen 的。

@interface RNSModalScreen : RNSScreenView
@end

这个为什么需要分别定义两个组件?

在 iOS 中,modal 和普通的 view 有所区别,modal 需要脱离 Root Navigation Controller 进入一个新的 Navigation Controller 中。它是一个孤立的视图。

那么,光有 ViewController 肯定是不行的。我们还缺少一个管理 VC 的 Navigation Controller。还记得之前我们在使用 ScreenStackItem 的时候,需要包一个 ScreenStack 吗?

我们找到它,在 iOS 中对应 RNSScreenStack,它是一个 Navigation Controller。

@interface RNSNavigationController : UINavigationController <RNSViewControllerDelegate, UIGestureRecognizerDelegate>

@end

@interface RNSScreenStackView :
#ifdef RCT_NEW_ARCH_ENABLED
    RCTViewComponentView <RNSScreenContainerDelegate>
#else
    UIView <RNSScreenContainerDelegate, RCTInvalidating>
#endif

- (void)markChildUpdated;
- (void)didUpdateChildren;
- (void)startScreenTransition;
- (void)updateScreenTransition:(double)progress;
- (void)finishScreenTransition:(BOOL)canceled;

@property (nonatomic) BOOL customAnimation;
@property (nonatomic) BOOL disableSwipeBack;

#ifdef RCT_NEW_ARCH_ENABLED
#else
@property (nonatomic, copy) RCTDirectEventBlock onFinishTransitioning;
#endif // RCT_NEW_ARCH_ENABLED

@end

@interface RNSScreenStackManager : RCTViewManager <RCTInvalidating>

@end

页面切换的实现原理

现在 Navigation Controller 和 View Controller 都有了,那么 React Native Screens 是如何管理页面之间的切换并做出动画的呢。

我们知道在 iOS 中,使用在 Navigation Controller 上命令式调用 pushViewController 方法,就可以实现页面之间的切换。但是上一篇文章中的 demo 中,我们并没有调用任何原生方法,只是 React 这边的组件状态发生了更新。

还记得吗,回顾一下。

const Demo = () => {
  const [otherRoutes, setOtherRoutes] = useState<
    {
      screenId: string
      route: ReactNode
    }[]
  >([])
  const cnt = useRef(0)
  const pushNewRoute = useEventCallback(() => {
    const screenId = `new-route-${cnt.current}`
    cnt.current++
    setOtherRoutes((prev) => [
      ...prev,
      {
        screenId,
        route: (
          <ScreenStackItem
            style={StyleSheet.absoluteFill}
            key={prev.length}
            screenId={screenId}
            onDismissed={() => {
              setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
            }}
          >
            <View className="flex-1 items-center justify-center bg-white">
              <Text>New Route</Text>
            </View>
          </ScreenStackItem>
        ),
      },
    ])
  })
  return (
    <ScreenStack style={StyleSheet.absoluteFill}>
      <ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
        <View className="flex-1 items-center justify-center bg-white">
          <Text>Root Route</Text>
          <Button title="Push New Route" onPress={pushNewRoute} />
        </View>
      </ScreenStackItem>
      {otherRoutes.map((route) => route.route)}
    </ScreenStack>
  )
}

我们只是通过更新 React Children 对应有几个 ScreenStackItem 组件,就实现了页面之间的切换。

那么,这个过程到底发生了什么呢?

其实都是在 RNSScreenStack 中处理的,通过比较更新前后的 children 数组,来决定是 push 还是 pop。


- (void)didUpdateReactSubviews
{
  // we need to wait until children have their layout set. At this point they don't have the layout
  // set yet, however the layout call is already enqueued on ui thread. Enqueuing update call on the
  // ui queue will guarantee that the update will run after layout.
  dispatch_async(dispatch_get_main_queue(), ^{
    [self maybeAddToParentAndUpdateContainer];
  });
}


- (void)maybeAddToParentAndUpdateContainer
{
  BOOL wasScreenMounted = _controller.parentViewController != nil;
  if (!self.window && !wasScreenMounted) {
    // We wait with adding to parent controller until the stack is mounted.
    // If we add it when window is not attached, some of the view transitions will be blocked (i.e.
    // modal transitions) and the internal view controler's state will get out of sync with what's
    // on screen without us knowing.
    return;
  }
  [self updateContainer];
  if (!wasScreenMounted) {
    // when stack hasn't been added to parent VC yet we do two things:
    // 1) we run updateContainer (the one above) – we do this because we want push view controllers to
    // be installed before the VC is mounted. If we do that after it is added to parent the push
    // updates operations are going to be blocked by UIKit.
    // 2) we add navigation VS to parent – this is needed for the VC lifecycle events to be dispatched
    // properly
    // 3) we again call updateContainer – this time we do this to open modal controllers. Modals
    // won't open in (1) because they require navigator to be added to parent. We handle that case
    // gracefully in setModalViewControllers and can retry opening at any point.
    [self reactAddControllerToClosestParent:_controller];
    [self updateContainer];
  }
}


- (void)updateContainer
{
  NSMutableArray<UIViewController *> *pushControllers = [NSMutableArray new];
  NSMutableArray<UIViewController *> *modalControllers = [NSMutableArray new];
  for (RNSScreenView *screen in _reactSubviews) {
    if (!screen.dismissed && screen.controller != nil && screen.activityState != RNSActivityStateInactive) {
      if (pushControllers.count == 0) {
        // first screen on the list needs to be places as "push controller"
        [pushControllers addObject:screen.controller];
      } else {
        if (screen.stackPresentation == RNSScreenStackPresentationPush) {
          [pushControllers addObject:screen.controller];
        } else {
          [modalControllers addObject:screen.controller];
        }
      }
    }
  }

  [self setPushViewControllers:pushControllers];
  [self setModalViewControllers:modalControllers];
}

当 React 组件的 children 发生变化会调用 didUpdateReactSubviews 方法。然后最后进入到 updateContainer 方法中。

updateContainer 方法中,会根据 RNSScreenViewstackPresentation 属性,来决定是 push 还是 pop。然后调用 setPushViewControllers 或者 setModalViewControllers 方法,来更新原生的视图。

setPushViewControllers 方法中调用原生的 pushViewController 方法。

所以,在 Native 中,整个 Navigation Controller 都是无状态的,他虽然存储 Controller 数组,但是只会比较前后得出需要过度的页面。

这也导致了,在 React 中如果你没有管理 ScreenStackItem,在触发 dismiss 之后,虽然看到页面返回了,但是再次点击进入之后就会推入比上一次 +1 的页面。

也因为这个原因,在 onDismissed 事件中,Native 无法告诉 React Native 这边被 dismiss 的页面是哪个,而是只能提供 dismiss 的数量。

onDismissed?: (e: NativeSyntheticEvent<{ dismissCount: number }>) => void;

下一步计划

好了,这篇文章就到这里了。篇幅已经有点长了。

那么,下一篇文章,我们再来实现一个简单的 Navigation 类吧。

看完了?说点什么呢

Monthly Issue - 2025.3

2025年3月25日 23:49
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/188

驾驶之路

前段日子去杭州,一个人开过了国道也开过了高速。高速虽然速度快,但是路很宽,也很好开;反而走国道,不仅慢,红灯多,还会有很多小路,路上大车也很多,比较磨练技术。

去杭州的两周末,也多开开大城市的市区道路,分岔路,高架隧道,提升下认路的能力。

目前为止,差不多也快开了四千公里了。

双影奇境

双人成行的续作。

有幸和 Soya 哥玩上了。爽玩五天,配合的非常默契,花了十几个小时就把主线和支线都通关了。

不得不说,这样的双人协作游戏太好玩了。最后一章的四个场景变换太赞了。

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

旅程

湘湖 3/1

湖州 太湖 3/16

:::grid {type=images,cols=3,rows=2,gap=16} :::

云澜湾 3/22

:::grid {type=images,cols=3,rows=3,gap=16} :::

雅迪工厂 3/24

:::grid {type=images,cols=2,rows=2,gap=16} :::

看完了?说点什么呢

使用 React Native Screens 构建一个 Native Navigation(一)

2025年3月21日 00:52
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/building-native-navigation-with-react-native-screens-part-1

React Navigation 与 React Native Screens

在使用 React Native 编写 app 时,通常都会选择使用 React Navigation或者 Expo Router 作为 Navigation。Expo Router 的本质只是对 React Navigation 上层封装。在使用 React Navigation 时也有两种选择。使用完全由 React Native 模拟的类原生 Navigation 样式和使用 Native Navigation 的容器实现。前者是 UI 模仿的和原生几乎一致但是并不是原生的 Navigation 而是由 JS 实现(@react-navigation/elements);后者这是原生 Navigation 实现,并和 React Native 桥接而成(@react-navigation/native-stack)。

这里我们不讨论前者。后者的 Native Navigation 背后的实现其实并不是由 React Navigation 提供的,在 react-navigation 的 repo 中并没有任何 native code。它背后的实现都是由 react-native-screens 提供的。但不幸的是,react-native-screens 作为下层依赖,官方并没有给出使用文档,只能我们去探索。

我们分析 @react-navigation/native-stack 的实现原理就能知道 react-native-screens 的具体用法,然后编写一个更加简易的 Navigation 了。

深入 React Navigation Native Stack

@react-navigation/native-stack 是 React Navigation repo 中的一个子包,位于 packages/native-stack 。其中 NativeStackView.native.tsx 是与 react-native-screens 建立联系的关键组件。

我们看到 NativeStackView 的组件部分定义如下:

<SafeAreaProviderCompat>
  <ScreenStack style={styles.container}>
    {state.routes.concat(state.preloadedRoutes).map((route, index) => {
      return (
        <SceneView
          key={route.key}
          index={index}
          focused={isFocused}
          descriptor={descriptor}
          previousDescriptor={previousDescriptor}
          nextDescriptor={nextDescriptor}
          isPresentationModal={isModal}
          isPreloaded={isPreloaded}
        />
      )
    })}
  </ScreenStack>
</SafeAreaProviderCompat>

SceneView 的部分定义如下:

<NavigationContext.Provider value={navigation}>
  <NavigationRouteContext.Provider value={route}>
    <ScreenStackItem
      key={route.key}
      screenId={route.key}
      activityState={isPreloaded ? 0 : 2}
      style={StyleSheet.absoluteFill}
      accessibilityElementsHidden={!focused}
      importantForAccessibility={focused ? 'auto' : 'no-hide-descendants'}
      customAnimationOnSwipe={animationMatchesGesture}
      fullScreenSwipeEnabled={fullScreenGestureEnabled}
      fullScreenSwipeShadowEnabled={fullScreenGestureShadowEnabled}
      freezeOnBlur={freezeOnBlur}
      gestureEnabled={
        Platform.OS === 'android'
          ? // This prop enables handling of system back gestures on Android
            // Since we handle them in JS side, we disable this
            false
          : gestureEnabled
      }
      homeIndicatorHidden={autoHideHomeIndicator}
      hideKeyboardOnSwipe={keyboardHandlingEnabled}
      navigationBarColor={navigationBarColor}
      navigationBarTranslucent={navigationBarTranslucent}
      navigationBarHidden={navigationBarHidden}
      replaceAnimation={animationTypeForReplace}
      stackPresentation={presentation === 'card' ? 'push' : presentation}
      stackAnimation={animation}
      screenOrientation={orientation}
      sheetAllowedDetents={sheetAllowedDetents}
      sheetLargestUndimmedDetentIndex={sheetLargestUndimmedDetentIndex}
      sheetGrabberVisible={sheetGrabberVisible}
      sheetInitialDetentIndex={sheetInitialDetentIndex}
      sheetCornerRadius={sheetCornerRadius}
      sheetElevation={sheetElevation}
      sheetExpandsWhenScrolledToEdge={sheetExpandsWhenScrolledToEdge}
      statusBarAnimation={statusBarAnimation}
      statusBarHidden={statusBarHidden}
      statusBarStyle={statusBarStyle}
      statusBarColor={statusBarBackgroundColor}
      statusBarTranslucent={statusBarTranslucent}
      swipeDirection={gestureDirectionOverride}
      transitionDuration={animationDuration}
      onWillAppear={onWillAppear}
      onWillDisappear={onWillDisappear}
      onAppear={onAppear}
      onDisappear={onDisappear}
      onDismissed={onDismissed}
      onGestureCancel={onGestureCancel}
      onSheetDetentChanged={onSheetDetentChanged}
      gestureResponseDistance={gestureResponseDistance}
      nativeBackButtonDismissalEnabled={false} // on Android
      onHeaderBackButtonClicked={onHeaderBackButtonClicked}
      preventNativeDismiss={isRemovePrevented} // on iOS
      onNativeDismissCancelled={onNativeDismissCancelled}
      onHeaderHeightChange={Animated.event(
        [
          {
            nativeEvent: {
              headerHeight: rawAnimatedHeaderHeight,
            },
          },
        ],
        {
          useNativeDriver,
          listener: (e) => {
            if (
              Platform.OS === 'android' &&
              (options.headerBackground != null || options.headerTransparent)
            ) {
              // FIXME: On Android, we get 0 if the header is translucent
              // So we set a default height in that case
              setHeaderHeight(ANDROID_DEFAULT_HEADER_HEIGHT + topInset)
              return
            }

            if (
              e.nativeEvent &&
              typeof e.nativeEvent === 'object' &&
              'headerHeight' in e.nativeEvent &&
              typeof e.nativeEvent.headerHeight === 'number'
            ) {
              const headerHeight =
                e.nativeEvent.headerHeight + headerHeightCorrectionOffset

              // Only debounce if header has large title or search bar
              // As it's the only case where the header height can change frequently
              const doesHeaderAnimate =
                Platform.OS === 'ios' &&
                (options.headerLargeTitle || options.headerSearchBarOptions)

              if (doesHeaderAnimate) {
                setHeaderHeightDebounced(headerHeight)
              } else {
                setHeaderHeight(headerHeight)
              }
            }
          },
        },
      )}
      contentStyle={[
        presentation !== 'transparentModal' &&
          presentation !== 'containedTransparentModal' && {
            backgroundColor: colors.background,
          },
        contentStyle,
      ]}
      headerConfig={headerConfig}
      unstable_sheetFooter={unstable_sheetFooter}
    ></ScreenStackItem>
  </NavigationRouteContext.Provider>
</NavigationContext.Provider>

首先我们从 Stack 组件中,看到最外层的 SafeAreaProvider 这是由 react-native-safearea-contexts 提供的。后面的 ScreenStack 是 react-native-screens 引入的。state.routes 是当前 Navigation 中存在的 routes,每一个 routes 都被一个 SceneView 组件消费的。

SceneView 组件主要也是对 ScreenStackItem 的一层封装。ScreenStackItem 也是 react-native-screens 引入的。

根据这个原理我们已经发现了基本的使用方法。

<ScreenStack>
  <ScreenStackItem></ScreenStackItem>
  <ScreenStackItem></ScreenStackItem>
</ScreenStack>

当我们从一个页面跳转到另一个页面时,会创建一个新的 ScreenStackItem 组件,并将其添加到 ScreenStack 中。

现在我们来实践一下这个发现。

import { ScreenStack, ScreenStackItem } from 'react-native-screens'

const Demo = () => {
  const [otherRoutes, setOtherRoutes] = useState<ReactNode[]>([])
  const pushNewRoute = useEventCallback(() => {
    setOtherRoutes((prev) => [
      ...prev,
      <ScreenStackItem
        style={StyleSheet.absoluteFill}
        key={prev.length}
        screenId={`new-route-${prev.length}`}
      >
        <View className="flex-1 items-center justify-center bg-white">
          <Text>New Route</Text>
        </View>
      </ScreenStackItem>,
    ])
  })
  return (
    <ScreenStack style={StyleSheet.absoluteFill}>
      <ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
        <View className="flex-1 items-center justify-center bg-white">
          <Text>Root Route</Text>
          <Button title="Push New Route" onPress={pushNewRoute} />
        </View>
      </ScreenStackItem>
      {otherRoutes.map((route) => route)}
    </ScreenStack>
  )
}

注意 ScreenStackScreenStackItem 都是 Native 组件,React Native 并不感知其组件尺寸,所以你需要使用 style 属性来指定其尺寸,对于整个容器,一般使用 StyleSheet.absoluteFill 来指定。

从上面视频可以看到,我们点击按钮后,新的页面出现了,并且和原生的行为一致。

但是,我们很快注意到,当我们返回新页面之后,虽然在 UI 上已经返回到了上级页面,但是在 React 中的状态没有更新,导致下次再点击按钮时,会创建多次新页面。

我们很快注意到 ScreenStackItem 提供了众多生命周期回调。

  • onWillAppear: 原生 ViewController viewWillAppear 回调
  • onWillDisappear: 原生 ViewController viewWillDisappear 回调
  • onAppear: 原生 ViewController didAppear 回调
  • onDisappear: 原生 ViewController didDisappear 回调
  • onDismissed: ViewController 被销毁时回调

这里我们使用 onDismissed 来更新 React 中的状态。

const Demo = () => {
  const [otherRoutes, setOtherRoutes] = useState<
    {
      screenId: string
      route: ReactNode
    }[]
  >([])
  const cnt = useRef(0)
  const pushNewRoute = useEventCallback(() => {
    const screenId = `new-route-${cnt.current}`
    cnt.current++
    setOtherRoutes((prev) => [
      ...prev,
      {
        screenId,
        route: (
          <ScreenStackItem
            style={StyleSheet.absoluteFill}
            key={prev.length}
            screenId={screenId}
            onDismissed={() => {
              setOtherRoutes((prev) => prev.filter((route) => route.screenId !== screenId))
            }}
          >
            <View className="flex-1 items-center justify-center bg-white">
              <Text>New Route</Text>
            </View>
          </ScreenStackItem>
        ),
      },
    ])
  })
  return (
    <ScreenStack style={StyleSheet.absoluteFill}>
      <ScreenStackItem screenId="root" style={StyleSheet.absoluteFill}>
        <View className="flex-1 items-center justify-center bg-white">
          <Text>Root Route</Text>
          <Button title="Push New Route" onPress={pushNewRoute} />
        </View>
      </ScreenStackItem>
      {otherRoutes.map((route) => route.route)}
    </ScreenStack>
  )
}

到此为止,我们已经大致了解了 react-native-screens 的用法。

写的太长没人看,先写到这里。下一篇继续深入 react-native-screens。

看完了?说点什么呢

React Native 预载 WebView 加速内容呈现

2025年3月20日 00:09
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/react-native-preload-webview-to-speed-up-content-rendering
本文档仅适用于 Expo 52 以上版本。原生组件使用 Swift 编写,适用于 iOS 平台。安卓暂不考虑。

在使用 React Native 编写 App 中,或许会需要 WebView 的地方,例如展示一段 HTML,渲染一个 RSS 内容等。常规的使用 react-native-webview,那么很明显在页面出现之后,还要等待 WebView 加载完相应的 HTML 才能显示内容。例如下面这个简单的例子:

即便是一段简单的 Markdown 文本的渲染,也会出现短暂的空白。

那么,如果想要在页面出现时,内容就已经出现,看上去就和原生渲染一样,那么就需要预载 WebView 的内容。

想法

  1. 我们需要构建一个网页,此网页需要支持通过传递 Props 动态展示需要展示的内容。
  2. 在 App 的某个时机,或者进入某个模块时,加载 WebView 并且预加载网页,但是处于离屏状态
  3. 在某个时机,比如在触发 Navigate 时,传递需要展示的数据 Props 给 WebView 组件,处于离屏的 WebView 收到 Props 后,立即触发重渲染(耗时低)。
  4. 下一屏 View 展示 WebView 组件,WebView 从离屏移动到屏幕上。

也就是说,我们需要实现一个后台常驻的 WebView, 并且只需要通过桥传递不同的数据,即可展示不同的内容。

编写原生 WebView 组件

为了实现这个效果,我们需要使用编写原生组件。下面以 Expo Module + iOS 为例。

首先我们需要编写一个 Expo Module。就叫 SharedWebViewModule 吧。

import ExpoModulesCore
public class SharedWebViewModule: Module {
    public func definition() -> ModuleDefinition {
        Name("SharedWebViewModule")
    }
}

写一个 WebViewManager 来管理 WebView。

import ExpoModulesCore
import SwiftUI
@preconcurrency import WebKit

private var pendingJavaScripts: [String] = []


protocol WebViewLinkDelegate: AnyObject {
    func webView(_ webView: WKWebView, shouldOpenURL url: URL)
}

enum WebViewManager {
    static var state = WebViewState()

    public static func evaluateJavaScript(_ js: String) {
        DispatchQueue.main.async {
            guard let webView = SharedWebViewModule.sharedWebView else {
                pendingJavaScripts.append(js)
                return
            }
            guard webView.url != nil else {
                pendingJavaScripts.append(js)
                return
            }

            if webView.isLoading {
                pendingJavaScripts.append(js)
            } else {
                webView.evaluateJavaScript(js)
            }
        }
    }

    static private(set) var shared: WKWebView = {
        SharedWebView(frame: .zero, state: state)
    }()

    static func resetWebView() {
        self.state = WebViewState()
        self.shared = FOWebView(frame: .zero, state: state)
    }
}

这里也实现了 evaluateJavaScript 方法,用于在后台执行 JavaScript 代码。

接下来编写 View, 就叫 ShareWebView 吧。

import Combine
import ExpoModulesCore
import SnapKit
import SwiftUI
import WebKit

class ShareWebView: ExpoView {
    private var cancellable: AnyCancellable?
    private let rctView = RCTView(frame: .zero)

    required init(appContext: AppContext? = nil) {
        super.init(appContext: appContext)
        addSubview(rctView)

        rctView.addSubview(SharedWebViewModule.sharedWebView!)

        clipsToBounds = true
        cancellable = WebViewManager.state.$contentHeight
            .receive(on: DispatchQueue.main)
            .sink { [weak self] _ in
                self?.layoutSubviews()
            }
    }

    deinit {
        cancellable?.cancel()
    }

    private let onContentHeightChange = ExpoModulesCore.EventDispatcher()

    override func layoutSubviews() {
        let rect = CGRect(
            x: bounds.origin.x,
            y: bounds.origin.y,
            width: bounds.width,
            height: WebViewManager.state.contentHeight
        )
        guard let webView = SharedWebViewModule.sharedWebView else { return }
        webView.frame = rect
        webView.scrollView.frame = rect

        frame = rect
        rctView.frame = rect
        onContentHeightChange(["height": Float(rect.height)])

    }
}

我们还需要去托管一下相关的状态,例如内容高度。

import Combine
import UIKit

class WebViewState: ObservableObject {
  @Published var contentHeight: CGFloat = UIWindow().bounds.height
}

然后把 View 注册到 Module 中。

public class SharedWebViewModule: Module {
    public func definition() -> ModuleDefinition {
        Name("SharedWebViewModule")

        View(ShareWebView.self) {
            Events("onContentHeightChange")
        }

        Function("evaluateJavaScript") { (js: String) in
            WebViewManager.evaluateJavaScript(js)
        }


    }
}

接下来我们还需要实现一个加载 URL 的方法。这里需要注意本地路径。

class SharedWebViewModule: Module {

    private func load(urlString: String) {
        guard let webView = SharedWebViewModule.sharedWebView else {
            return
        }

        let urlProtocol = "file://"
        if urlString.starts(with: urlProtocol) { // 判断本地路径
            let localHtml = self.getLocalHTML(from: urlString)

            if let localHtml = localHtml {
                webView.loadFileURL(
                    localHtml,
                    allowingReadAccessTo: localHtml.deletingLastPathComponent()
                )

                debugPrint("load local html: \(localHtml.absoluteString)")

                return
            }
        }

        if let url = URL(string: urlString) {
            if url == webView.url {
                return
            }
            debugPrint("load remote html: \(url.absoluteString)")
            webView.load(URLRequest(url: url))
        }
    }

    private func getLocalHTML(from fileURL: String) -> URL? {
        if let url = URL(string: fileURL), url.scheme == "file" {

            let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences(
                of: "file://", with: ""
            )
            let fileName = url.lastPathComponent
            let fileExtension = url.pathExtension

            if let fileURL = Bundle.main
                .url(
                    forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)),
                    withExtension: fileExtension,
                    subdirectory: directoryPath
                )
            {

                return fileURL
            } else {
                return nil

            }
        } else {
            debugPrint("Invalidate url")
            return nil
        }
    }
}

设置 View 的 url 参数:

class SharedWebViewModule: Module {
  public func definition() -> ModuleDefinition {

        View(WebViewView.self) {
            Events("onContentHeightChange")

            Prop("url") { (_: UIView, urlString: String) in // 设置 url 参数
                DispatchQueue.main.async {
                    self.load(urlString: urlString)
                }
            }
        }

        Function("load") { (url: String) in // 加载 url 的方法
            self.load(urlString: url)
        }
    }
}

注册 Expo Module:

// expo-module.config.json

{
  "platforms": ["apple", "android"],
  "apple": {
    "modules": ["SharedWebViewModule"]
  },
  "android": {
    "modules": []
  }
}

使用

import { requireNativeView } from "expo"
const NativeView: React.ComponentType<
  ViewProps & {
    onContentHeightChange?: (e: { nativeEvent: { height: number } }) => void
    url?: string
  }
> = requireNativeView("SharedWebViewModule")

<NativeView /> // 在任意子页面添加这个组件

在某个时机提前预载 WebView 内容:

import { Image, Platform } from 'react-native'

const assetPath = Image.resolveAssetSource({
  uri: 'rn-web/html-renderer', // 这个路径是 XCode 的 Bundle Resources
  // 可以参考: https://github.com/RSSNext/Follow/blob/995b269260541fd85beba3d050401c499463e2b1/apps/mobile/scripts/with-follow-assets.js
}).uri
export const htmlUrl = Platform.select({
  ios: `file://${assetPath}/index.html`,
  default: '',
})

const prepareOnce = false
export const prepareEntryRenderWebView = () => {
  if (prepareOnce) return
  prepareOnce = true
  SharedWebViewModule.load(htmlUrl)
}

事件传递数据更新内容

在 Web app 中,我们借助任何外部状态管理库,例如 jotai,即可实现状态的传递和 UI 的响应式的更新。

例如,我们在 Web app 中定义了如下状态,并且方法暴露到全局:

const store = createStore()

Object.assign(window, {
  setEntry(entry: EntryModel) {
    store.set(entryAtom, entry)
    bridge.measure()
  },
  setCodeTheme(light: string, dark: string) {
    store.set(codeThemeLightAtom, light)
    store.set(codeThemeDarkAtom, dark)
  },
  setReaderRenderInlineStyle(value: boolean) {
    store.set(readerRenderInlineStyleAtom, value)
  },
  setNoMedia(value: boolean) {
    store.set(noMediaAtom, value)
  },
  setShowReadability(value: boolean) {
    store.set(showReadabilityAtom, value)
  },
  reset() {
    store.set(entryAtom, null)
    bridge.measure()
  },
})

在 WebView 中,我们可以通过 evaluateJavaScript 方法,执行 JavaScript 代码,从而更新状态。

那么在 React Native 中我们使用 Module 暴露的 evaluateJavaScript 方法,即可实现状态的传递和 UI 的响应式的更新。

例如定义下面的方法:

const setWebViewEntry = (entry: EntryModel) => {
  SharedWebViewModule.evaluateJavaScript(
    `setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`,
  )
}
export { setWebViewEntry as preloadWebViewEntry }

在触发 Navigation 前置,提前在后台预载:

const handlePressPreview = useCallback(() => {
  preloadWebViewEntry(entry) // 预载
  navigation.pushControllerView(EntryDetailScreen, { // 触发 Navigation
    entryId: id,
    view: view!,
  })
}, [entry, id, navigation, view])

大致思路就是这样啦。大功告成。

来看看效果:

最后所有的代码都是开源的。

上面的代码具体实现,在 Follow 这个项目中,大家可以点点 Star 哦。

https://github.com/RSSNext/Follow/tree/dev/apps/mobile/native/ios/Modules/SharedWebView

https://github.com/RSSNext/Follow

看完了?说点什么呢

迷雾

2025年2月27日 23:43
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/187

又是半个月,分享一下最近发生的一些事。

项目进展

最近,还是在写 Follow(透露一下,之后可能会改个名),精力全部在编写移动端上,由于是使用 React Native 去写的,很多时候最终的效果都不是很好。有时为了一个想要的效果,必须去调用 native 的方法。不得不花了大量的时间去调研 native,最近最常看的也是 React Native、Expo、React Navigation、React Screen 这四个又爱又恨但是每个 RN 项目都离不开的东西了。如今虽然有 AI 的帮助,但是对于 Native 来说,它在很多时候都是在一本正经的乱说,加上网上对于 native 的分享并不多,加上又是 React Native 的实现,只能去看源码和猜谜。花了很多时间想去实现的东西,可能最后只是花了很多时间而已,而再也没有后文了。这就样,最近很多时间都是在无效打工,这也让我有很长的时间有些怀疑自己和变得很郁闷。虽说很痛苦,但也有些确实实现了,感到十分的激动。当然这背后,还是要非常感谢那位大师

算上这次,加上之前的好几次,我已经算不出第几次入门 Swift 了。最早的时候,大概是在 20 年的时候,那时候刚出 SwiftUI,虽然功能非常局限,但让我一个写 React 的,上手还是比较快的。但是由于不会 UIKit,很多都不能达到想要的效果,最后也是不了了之了。后来陆陆续续接触过一些 UIKit 但不多,慢慢的也淡忘了。希望这次,借助 React Native 和 native 的桥梁,能学习的跟深入一点。

日常

上周去杭州参加了 pseudoyu 的婚礼,章鱼为他们的婚礼还准备了一场特别的展览,介绍他们相恋期间的事情。

晚上的婚宴,经过一场浪漫的开场后,之后也算是开启了一场公司团建的饭局(公司同事一桌),作为远程工作者,这也是我第一次在这家公司里和这么多其他同事见面了。之后,一起去了聚会,然后打起了德州,还挺有意思。

毕业工作两年多了,有一年多的时间都在家里,由于不需要通勤,而我又是一个不爱出门的人。加上不锻炼,体重也是越来越重了。身体机能确实是越来越不行了。前些天和之前小红书的同事聊起,大家也都是长胖了。长时间的久坐和加班压力,让内分泌失调也是加剧这个现象。感觉也是需要注意一下身体了。

开源

终于,把 Shiro 升级到 Next.js 15 了。但是开源版本还没有升级,由于现在两者代码差异越来越大,所以暂且不会对开源版本做框架升级了。

对了,最近写了一个小玩具,用来代替 GitHub 的 Notification,GitHub 的那个太丑太难用了。

因为风格是参考的 Linear,所以我也叫 Linear 了。

https://github.com/Innei/linear

看完了?说点什么呢

久别重逢的生活碎片

2025年2月19日 21:24
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/186

前些日子,和久违的高中同学见面一起吃了个饭。

时间过得很快,转眼间大学也毕竟毕业两年多了。据我所了解的,我那届高中同学,有很多在大学毕业之后没有就业而是选择了继续深造。两年的时间又过去了,不少研究生也已经毕业,参加工作了。

从聊天中得知,有些人在本地找到了一份稳定的工作,虽然收入不高,但是可持续发展。也有正在经历研究生的最后一年时光,正在焦虑论文和就业。有人会羡慕我,没有选择读研而是毕业就参加工作,如今已经工作两年,而他们选择了深造却仍然能找到合适的工作。我想着大概是围城,早早成为社畜的我,也很是羡慕那校园生活。

留在小城市,找一份稳定的工作也许是一个最保守的选择,但也是一个一眼就能看到头的工作。但我并不喜欢这样。作为一个程序员,我希望每天都需要做不同以往有挑战性的事情。但也深知这是一个颠沛流离的职业,终点是一个未知。我现在很好,但是有时候还是会忍不住去想,未来的路到底会通向哪里。

最近生活中也出了些小插曲。

同学开我车子时,磕到马路牙子了。维修花了 800,等了三天。也是长了教训,后面车子还是自己开,要不然万一出了事故不仅伤感情而且花时间也破费。

在火车站停车停了一天,回来发现被开门杀了。回到家开始翻哨兵,看了几百个视频发现了肇事者。果断报警了。后续去事故中队处理,肇事者也认了,最后赔了 500。

看完了?说点什么呢

在 Expo 中使用 Native 组件

2025年2月9日 23:59
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/use-native-components-in-expo

目前,较为流行的 React Native 框架是 Expo,Expo 提供了 Expo Modules API 可供我们快速的开发一个 Native 组件或者模块。

这篇文章将简单介绍如何在 Expo ~52 中在当前项目中初始化并编写一个简单的 Native 组件。

准备

开始之前,确保你已经建立了一个 Expo 的项目。

然后,通过以下命令创建一个 Expo Module。

npx create-expo-module@latest --local

根据引导创建了一个 Module。位于 modules/<name> 下,并存在以下的文件。

.
├── android
│   ├── build.gradle
│   └── src
│       └── main
│           ├── AndroidManifest.xml
│           └── java
│               └── expo
│                   └── modules
│                       └── testmodule
│                           ├── TestModule.kt
│                           └── TestModuleView.kt
├── expo-module.config.json
├── index.ts
├── ios
│   ├── TestModule.podspec
│   ├── TestModule.swift
│   └── TestModuleView.swift
└── src
    ├── TestModule.ts
    ├── TestModule.types.ts
    ├── TestModule.web.ts
    ├── TestModuleView.tsx
    └── TestModuleView.web.tsx

10 directories, 14 files

其中,android 和 ios 目录是编写 native 代码的入口。

集成到现有项目中

现在我们需要把这种模块应用到当前的 app 中。

编辑 package.json 增加这个字段:

 "expo": {
    "autolinking": {
      "nativeModulesDir": "./modules"
    }
  },

然后,执行 npm run expo prebuild --clean 刷新 ios 和 android 的项目。

等待安装完 native 依赖之后,我们使用 Xcode 打开 xcworkspace 文件。

编写一个简易模块

在初始模板中已经存在了一个官方实例模块。我们也可以尝试自己写一个简单的组件。下面以 iOS 平台并使用 Swift UI 编写。

根据图示找到我们的模块位置。

新建一个内部模块,用以下的方式组织文件:

../ListView
├── ListView.swift
└── ListViewModule.swift

ListView.swift 中编写以下代码,一个简单 SwiftUI List:

import SwiftUI

struct ListView: View {
    var body: some View {
        List {
            ForEach(1...10, id: \.self) { index in
                Text("\(index)")
            }
        }
    }
}

#Preview {
    ListView()
}

然后定义 Expo Module,编辑 ListViewModule.swift

import ExpoModulesCore
import SwiftUI

class ListViewProps: ExpoSwiftUI.ViewProps {
    
}

fileprivate struct ListViewExpoView: ExpoSwiftUI.View {
    @EnvironmentObject var props: ListViewProps
    var body: some View {
        ListView()
    }
}

public class ListViewModule: Module {
    public func definition() -> ModuleDefinition {
        Name("ListView") // 代表之后调用 requireNativeView 使用的标识符
        
        View(ListViewExpoView.self) // 使用 ListViewExpoView
    }
}

到这里,native 侧已经完成了。接下来修改 expo-module.config.json

增加刚刚新增的 ListViewModule

{
  "platforms": [
    "apple",
    "android",
    "web"
  ],
  "apple": {
    "modules": [
      "TestModule",
      "ListViewModule" // 增加
    ]
  },
  "android": {
    "modules": [
      "expo.modules.testmodule.TestModule"
    ]
  }
}

由于我们没有实现 android,这里就不写了。然后执行 npm expo prebuild

在 React Native 中使用 Native 组件

经过了上面的步骤,下面在 React Native 中这样使用它。

import { requireNativeView } from 'expo'
import type { ViewProps } from 'react-native'

interface ListViewProps extends ViewProps {}
const ListView = requireNativeView<ListViewProps>('ListView') // 使用上面定义的 Name
export default function HomeScreen() {
  return <ListView style={{ flex: 1 }} />
}

然后在 Xcode 中启动项目,同时启动 metro dev server

通过 Xcode View Hierarchy 看到,NativeView 已经正常渲染。

看完了?说点什么呢

新年的路

2025年2月3日 00:28
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/185

许久没有写代码了,原因是最近自驾去了挺多地方。

年前,去了海宁神仙湖。第一次上高速。

:::gallery https://object.innei.in/bed/2025/0202_1738511753954.jpeg https://object.innei.in/bed/2025/0202_1738511757142.jpeg :::

冬天去拍不是很出片,只能拍到黄黄的一片,下次夏天去景色应该会很好。

然后去了南北湖。比较一般。没咋拍。

除夕前一晚,去了巧克力小镇。也比较一般。

除夕那天,城里一片宁静,但是在三环上,是一个分水岭。

同时,去了附近的景区,也是有很多老板按箱放烟花的。

初一,去了山海几千重。看了篝火表演和烟花,可惜没找到打铁花的表演位置

后面去了盐官。但是没看到潮水,刚好错过了。

去了大风车。

过年期间,不管是哪个有点名气的景点人都挺多的。

上面的航拍都是用 DJI mini 4K 拍的,入门级无人机,刚入的。有点后悔买了这个,当前主要是为了练着玩玩,炸了也不难过。但是画质真的挺差的。

提车两个月,已经跑了 2300km+。

好久没有写代码了,还是感到非常的焦虑。

看完了?说点什么呢

React Native 实践:Colors

2025年1月26日 21:57
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/react-native-uikit-colors

开始编写 Follow Mobile 已经过去一个月了,想想也该沉淀点什么东西了。

这篇文章首先来讲讲 Follow Mobile 的颜色体系。

开始之前需要知道的是 Follow Mobile 是使用 React Native 开发的并且使用了 Expo 框架。

准备条件

由于 React Native 并没有官方支持 web 中 className 的写法,为了适应 web 中方便快捷的 TailwindCSS 原子类名,我们需要借助 NativeWind 工具。这是一个能让 React Native app 中也使用一部分 TailwindCSS 能力的编译器。通过 babel plugin 对 React Native 中的基础组件进行包装,在 runtime 中对 className 进行翻译到 React Native style 对象来实现类似效果。

NativewindCSS 内部也借助 TailwindCSS 进行翻译,在配置 TailwindCSS 时基本和 Web 中一致。

我们安装 NativewindCSS。

pnpm install nativewind tailwindcss

配置好 Babel 和 Metro。

// babel.config.js
module.exports = function (api) {
  api.cache(true)
  return {
    presets: [
      ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
      'nativewind/babel',
    ],
  }
}
const { withNativeWind } = require('nativewind/metro')
module.exports = withNativeWind(config, { input: './src/global.css' })

创建 PostCSS 样式入口。

/* src/global.css  */
@tailwind base;
@tailwind components;
@tailwind utilities;

在 dev server 启动之后,会自动生成 nativewind-env.d.ts 文件以提供类型支持。至此,准备工作已经完成。

选择颜色体系

一个 app 起步阶段,或许一套完善的设计规范是不可少的。颜色定义对于一个 app 来说也是重中之重。在开始开发 app 时,我想要打造一个很 apple 味道的 app。

可惜,React Native 毕竟是一个跨段开发框架,并没有提供太多的 native 组件和样式。多数我们只能去模拟 native 样式或者借助社区的 native 模块。对于组件,这是后话了,这里我先说说颜色。

apple 有一套非常规范的颜色体系,在 Color | Apple Developer Documentation。使用这套定义,搬到 NativeWind 中。

可能在文档中并不好看到所有颜色的值,apple 提供了 figma,在这里还可以看到大部分的 native 组件使用的颜色。

https://www.figma.com/community/file/1385659531316001292/ios-18-and-ipados-18

那么这里,我已经把相关颜色都提取出来了。供你参考:

const lightPalette = {
  red: '255 59 48',
  orange: '255 149 0',
  yellow: '255 204 0',
  green: '52 199 89',
  mint: '0 199 190',
  teal: '48 176 190',
  cyan: '50 173 200',
  blue: '0 122 255',
  indigo: '88 86 214',
  purple: '175 82 222',
  pink: '255 45 85',
  brown: '162 132 94',
  gray: '142 142 147',
  gray2: '172 172 178',
  gray3: '199 199 204',
  gray4: '209 209 214',
  gray5: '229 229 234',
  gray6: '242 242 247',
}
const darkPalette = {
  red: '255 69 58',
  orange: '255 175 113',
  yellow: '255 214 10',
  green: '48 209 88',
  mint: '99 230 226',
  teal: '64 200 244',
  cyan: '100 210 255',
  blue: '10 132 255',
  indigo: '94 92 230',
  purple: '191 90 242',
  pink: '255 55 95',
  brown: '172 142 104',
  gray: '142 142 147',
  gray2: '99 99 102',
  gray3: '72 72 74',
  gray4: '58 58 60',
  gray5: '44 44 46',
  gray6: '28 28 30',
}

export const lightVariants = {
  // UIKit Colors

  placeholderText: '199 199 204',
  separator: '84 84 86 0.34',
  opaqueSeparator: '84 84 86 0.34',
  nonOpaqueSeparator: '198 198 200',
  link: '0 122 255',

  systemBackground: '255 255 255',
  secondarySystemBackground: '242 242 247',
  tertiarySystemBackground: '255 255 255',

  // Grouped
  systemGroupedBackground: '242 242 247',
  secondarySystemGroupedBackground: '255 255 255',
  tertiarySystemGroupedBackground: '242 242 247',

  // System Colors
  systemFill: '120 120 128 0.2',
  secondarySystemFill: '120 120 128 0.16',
  tertiarySystemFill: '120 120 128 0.12',
  quaternarySystemFill: '120 120 128 0.08',

  // Text Colors
  label: '0 0 0',
  text: '0 0 0',
  secondaryLabel: '60 60 67 0.6',
  tertiaryLabel: '60 60 67 0.3',
  quaternaryLabel: '60 60 67 0.18',
}
export const darkVariants = {
  // UIKit Colors

  placeholderText: '122 122 122',
  separator: '56 56 58 0.6',
  opaqueSeparator: '56 56 58 0.6',
  nonOpaqueSeparator: '84 84 86',
  link: '10 132 255',
  systemBackground: '0 0 0',
  secondarySystemBackground: '28 28 30',
  tertiarySystemBackground: '44 44 46',

  // Grouped
  systemGroupedBackground: '0 0 0',
  secondarySystemGroupedBackground: '28 28 30',
  tertiarySystemGroupedBackground: '44 44 46',

  // System Colors
  systemFill: '120 120 128 0.36',
  secondarySystemFill: '120 120 128 0.32',
  tertiarySystemFill: '120 120 128 0.24',
  quaternarySystemFill: '120 120 128 0.19',

  // Text Colors
  label: '255 255 255',
  text: '255 255 255',
  secondaryLabel: '235 235 245 0.6',
  tertiaryLabel: '235 235 245 0.3',
  quaternaryLabel: '235 235 245 0.18',
}

分别对应亮色和暗色下的普通颜色和系统变量颜色。

使用 NativeWind 变量注入

NativeWind 有个特征可以实现类似 Web 中的 CSS variable。

https://www.nativewind.dev/api/vars

例如:

<View style={vars({ '--brand-color': 'red'})}>
  { // style: { color: 'red' } }
  <Text className="text-[--brand-color]" />
</View>

借助这个特征,我们可以把上面的颜色定义都用这个方式从顶层传入。


// @ts-expect-error
const IS_DOM = typeof ReactNativeWebView !== 'undefined'

const varPrefix = '--color'

const buildVars = (_vars: Record<string, string>) => {
  const cssVars = {} as Record<`${typeof varPrefix}-${string}`, string>
  for (const [key, value] of Object.entries(_vars)) {
    cssVars[`${varPrefix}-${key}`] = value
  }

  return IS_DOM ? cssVars : vars(cssVars)
}

上面这个函数为了兼容 react-native-web 如果你没有需求可省略。

const mergedLightColors = {
  ...lightVariants,
  ...lightPalette,
}
const mergedDarkColors = {
  ...darkVariants,
  ...darkPalette,
}
const mergedColors = {
  light: mergedLightColors,
  dark: mergedDarkColors,
}

export const colorVariants = {
  light: buildVars(lightVariants),
  dark: buildVars(darkVariants),
}
export const palette = {
  // iOS color palette https://developer.apple.com/design/human-interface-guidelines/color
  light: buildVars(lightPalette),
  dark: buildVars(darkPalette),
}

export const getCurrentColors = () => {
  const colorScheme = Appearance.getColorScheme() || 'light'

  return StyleSheet.compose(
    colorVariants[colorScheme],
    palette[colorScheme],
  ) as StyleProp<ViewStyle>
}

然后在顶层包一层 View。例如:

export const RootProviders = ({ children }: { children: ReactNode }) => {
  useColorScheme() // 为了对亮色/暗色进行监听
  const currentThemeColors = getCurrentColors()!

  return <View style={[styles.flex, currentThemeColors]}>{children}</View>
}

这样,在子代任何组件都可以直接使用相关的变量了。但是使用仍然不方便。我们还需要配置下 TailwindCSS 的 colors。

由于上面我们都用了前缀 --color,我可以这样写一个 tailwindcss config 的包装函数。

import { Config } from 'tailwindcss'

const configColors = {
  // Palette colors
  red: 'rgb(var(--color-red) / <alpha-value>)',
  orange: 'rgb(var(--color-orange) / <alpha-value>)',
  yellow: 'rgb(var(--color-yellow) / <alpha-value>)',
  green: 'rgb(var(--color-green) / <alpha-value>)',
  mint: 'rgb(var(--color-mint) / <alpha-value>)',
  teal: 'rgb(var(--color-teal) / <alpha-value>)',
  cyan: 'rgb(var(--color-cyan) / <alpha-value>)',
  blue: 'rgb(var(--color-blue) / <alpha-value>)',
  indigo: 'rgb(var(--color-indigo) / <alpha-value>)',
  purple: 'rgb(var(--color-purple) / <alpha-value>)',
  pink: 'rgb(var(--color-pink) / <alpha-value>)',
  brown: 'rgb(var(--color-brown) / <alpha-value>)',
  gray: {
    DEFAULT: 'rgb(var(--color-gray) / <alpha-value>)',
    2: 'rgb(var(--color-gray2) / <alpha-value>)',
    3: 'rgb(var(--color-gray3) / <alpha-value>)',
    4: 'rgb(var(--color-gray4) / <alpha-value>)',
    5: 'rgb(var(--color-gray5) / <alpha-value>)',
    6: 'rgb(var(--color-gray6) / <alpha-value>)',
  },

  // System colors

  'placeholder-text': 'rgb(var(--color-placeholderText) / <alpha-value>)',
  separator: 'rgb(var(--color-separator) / <alpha-value>)',
  'opaque-separator': 'rgba(var(--color-opaqueSeparator))',
  'non-opaque-separator': 'rgba(var(--color-nonOpaqueSeparator))',
  link: 'rgb(var(--color-link) / <alpha-value>)',

  // Backgrounds
  'system-background': 'rgb(var(--color-systemBackground) / <alpha-value>)',
  'secondary-system-background':
    'rgb(var(--color-secondarySystemBackground) / <alpha-value>)',
  'tertiary-system-background':
    'rgb(var(--color-tertiarySystemBackground) / <alpha-value>)',
  'system-grouped-background':
    'rgb(var(--color-systemGroupedBackground) / <alpha-value>)',
  'secondary-system-grouped-background':
    'rgb(var(--color-secondarySystemGroupedBackground) / <alpha-value>)',
  'tertiary-system-grouped-background':
    'rgb(var(--color-tertiarySystemGroupedBackground) / <alpha-value>)',
  // System fills
  'system-fill': 'rgba(var(--color-systemFill))',
  'secondary-system-fill': 'rgba(var(--color-secondarySystemFill))',
  'tertiary-system-fill': 'rgba(var(--color-tertiarySystemFill))',
  'quaternary-system-fill': 'rgba(var(--color-quaternarySystemFill))',

  // Text colors
  label: 'rgb(var(--color-text) / <alpha-value>)',
  text: 'rgb(var(--color-text) / <alpha-value>)',
  'secondary-label': 'rgba(var(--color-secondaryLabel))',
  'tertiary-label': 'rgba(var(--color-tertiaryLabel))',
  'quaternary-label': 'rgba(var(--color-quaternaryLabel))',
}
export const withUIKit = (config: Config) => {
  config.theme = config.theme || {}
  config.theme.extend = config.theme.extend || {}
  config.theme.extend.colors = config.theme.extend.colors || {}
  config.theme.extend.colors = {
    ...config.theme.extend.colors,
    ...configColors,
  }
  return config
}

然后直接在 tailwind.config.ts 中使用。

export default withUIKit(config)

这样,在 tailwindcss 中就可以直接使用这些颜色了。

使用

在组件中,可以直接使用这样的方式去设置颜色:

<View className={'bg-secondary-system-grouped-background'} />

但是总有时候我们不能直接使用类名,而是需要实际的变量。比如在做颜色过渡动画的时候。

我们来写一个 hook 去获取当前主题时的对应颜色。

export const useColor = (color: keyof typeof mergedLightColors) => {
  const { colorScheme } = useColorScheme()
  const colors = mergedColors[colorScheme || 'light']
  return useMemo(() => rgbStringToRgb(colors[color]), [color, colors])
}

使用方式:

const redColor = useColor('red')

后记

此方案已从 Follow Mobile 项目中抽取为通用库,欢迎使用。

https://github.com/Innei/react-native-uikit-colors

看完了?说点什么呢

2024 · 前路未尽,初心犹在

2024年12月23日 00:21
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/184

那晚的街道

这一年的开始,一切都看似朝着好的方向发展。跨年的那一天,还和朋友们在一起。那一天,我们在乌镇,新的一年的起点,凌晨走在乌镇的街上,很冷清,吹着冷风,但是和朋友们在一起聊着各种的话题,让原本的冷清多了一份温暖。跨过一年的节点,仿佛前路也变得更加明朗了。

https://innei.in/notes/161

一次远行

年初的时候,(前)公司组织的一次团建。让我第一次踏上了离家两千多公里的中国西部——那是一个我未曾到过的地方。从上海启程,虽然因航班延误错过了第一个白天的行程,但大理的夜晚依然温柔地接纳了我。行程暂时停滞,但窗外的风景和远离城市喧嚣的安静,似乎也成为了一种“远程工作”以外的小确幸。短短几天,走过了大理古城,洱海S湾,丽江古城..,登上了玉龙雪山。在这次团建中,我和同事们一起交流、一起旅行,原本遥远的同事关系似乎因为这一段旅程而拉近了许多。

看到了日照金山,听说今年会行好运,可是真的吗?

https://innei.in/notes/162

那次突如其来的裁员

那一天,生活给我上了一堂意外的课。在远程工作的平静生活中,一切看似安稳,我也曾以为可以持续这样下去。然而,变故还是来临了。我收到了一个令人措手不及的通知:“项目组资金砍半,团队需要优化人员结构。” 回想起来,我仍然感到些许失望。从前公司离职后,我选择了一家更符合个人理想的公司,期待能与志同道合的伙伴们一起创造有意义的产品。但最终这段旅程提前结束了,这让我不得不思考:在职业发展中,如何在理想与现实之间找到平衡?

想到当初,(前)老板曾对我说:“希望你不要太早抛弃我们。” 但命运总是充满讽刺,最终被抛弃的却是我自己。那天的劝退,我依然记得清清楚楚,(前)老板说了一句:“以后我们也做不了朋友了。” 我知道,员工和老板之间本就不可能成为真正的朋友,但正因为之前关系还算融洽,这样的一句话,还是让我瞬间破防,心底的最后一丝自我安慰也被彻底击碎了。

https://innei.in/notes/170

别回头,向前看

那段日子,我被笼罩在阴影中,不断的自我怀疑,严重的抑郁情绪,也出现了躯体反应,开始治疗抑郁症了。那痛苦的半个月,看起来很可笑,还是自己的承受能力太差了。但是路还是要向前进的。

期间收到了很多朋友们的鼓励,谢谢你们。1

在后来,DIYGod 来找我问我要不要来做 Follow。现在想想也是多亏了当初忙里偷闲给 xLog 做的贡献,能在这个时候得到了一份工作。

加入 RSS3 时,Follow 还只是初具雏形,不管是 UI 设计还是代码的架构设计,我开始对这些大刀阔斧的改造,除此之外,还加上了我积累了多年的一些产品功能。这段日子里,每天都挺累的,但是从零开始做一个产品也挺快乐的。短短的三个月,两个人完成的 MVP,在 GitHub 上依次登上了日榜第一,周榜周一和月榜第一。我也见证了 Star 从我刚加入时的 1k 到目前的 20k。

做开源的日子里,大多数时间都是快乐的,也是一件特别有成就感的一件事。

沉浸与共鸣

今年,我通关了三款令我印象深刻的单机游戏

  • 《黑神话:悟空》2
  • 《最后生还者》1和2

《最后生还者》1 和 2 是当之无愧的经典。无论是剧情的深度还是玩法的巧妙,都让我不禁赞叹。这不仅仅是一个游戏,更是一场情感的旅程。“我们就听天由命吧,让最后的日子过得诗意一点,一起做一对末路狂花。” 。让人反思在末世的困境中,人与人之间的羁绊究竟有多重要。游戏玩法上相比传统的射击游戏,也更注重细节和策略,让整个体验更加丰富。

《黑神话:悟空》,作为首个国产 3A,必须支持。精美的画面、创新的玩法,都让我看到了国产游戏的潜力。不过,对我这种手残党来说,游戏的高难度简直是噩梦,尤其是不能调整难度的设定,后来只能靠风灵月影才得以通关。即便如此,还是能看到国产游戏也是在创新和进步的,相信以后也会有越来越多的国产 3A 大作的出现。

生活和远方

今年一整年都是 remote 的状态。相信大多数数字游民都会尝试旅居,在不同的地方体验不同的生活。但我这一年,依然没有去过更多地方。除了年初的团建,今年我只解锁了一个新地点:南京。

在南京,和一位群友面基了,也游览了一些特色景点。虽是短暂的旅途,也是别有一番滋味。

https://innei.in/notes/179

今年最大的一件事可能就是在年底时候买车了,特斯拉 Model 3。虽然我的驾照已经换本了,但是我还是不会开车。等未来学会了开车,或许会去更多的地方吧。

技术和成长

今年,我一共写了 27 篇技术类文章,另外加上写到一半鸽了的 Next.js 小册,总共可能有 40 篇左右。说来惭愧,被裁员之后就一直没有再继续写下去了,如今 Next.js 已经更新到 15,很多东西又不再适用了,而我渐渐地也不再继续探索和实践 Next.js 了,或许未来也会止步于此。但是,探索技术的道理不会停下,新的想法也一定会继续输出。也感谢各位读者朋友们的支持和鼓励。

今年的下半年,在大刀阔斧地对 Follow 的代码改造中,从对整体架构的设计到业务模块的划分又有了更深的理解和思考。我通过这个项目,在内部分享了复杂应用的数据流管理,也对外输出了很多的相关文章,包括不限于处理 i18n,打包优化,设计理念等等。

能够独立 O 一个比较大的项目,对自己的技术成长还是大有帮助的。而现在我正在 O Follow Mobile,相信不久的将来,能够让大家用上体验更好的 iOS 版本。

今年依然是输出满满的一年

里程碑

  • GitHub followers 1245 -> 2422
  • X 3550 -> 7385
  • 设备更新
    • iPhone 16
    • Nuphy Air 75 v2
    • iPad Pro 12.9 (2021)
    • 西昊 C500
    • HomePod mini x2

关于未来

我对未来并没有抱太多的期望了,我一向是如此悲观。我只希望,明年能够对我好一点。

往年回顾

看完了?说点什么呢

构想:Electron Render OTA 更新

2024年12月12日 00:00
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/electron-render-OTA-update

动机

  • 由于 App 的版本铺量存在碎片化,用户升级不及时或者不更新,会导致版本长期停留在旧版本无法得到最快的安全性或者其他功能修复。
  • 由于 Electron App 的全量更新需要更多的网络流量才能进行,对用户不友好。

拟使用热更的方式去动态更新 Electron 的 Renderer 层代码。

编写此文时,下面的实现正在 Follow 中实验。

https://github.com/RSSNext/follow

基础知识

Electron App 在生产环境下,默认会加载封装在 app.asar 中的代码,包括了 electron main 进程的代码,和 renderer 进程的代码。app.asar 一般在 app 解包或者安装路径的 resources 目录下,一种方式是我们可以直接替换这个 asar 文件然后重启 app 就行了,这种方式不仅能更新 renderer 还能更新 main 的代码,但是又两个问题是,第一这种方式是需要重启 app,而且在替换 app.asar 时需要解决文件占用的问题(Windows 专属),第二替换了 app.asar 会破坏 app 的完整性,导致签名失效(macOS 专属)。

在两者之间折中,只更新 renderer 的代码,并且不替换 app.asar 和破坏 app 完整性。

思路如下:当热更新资源存在时,并且验证此更新有效时,优先加载更新的 renderer 资源,否则使用 app.asar 的 renderer 资源。

热更实现

我们需要编写一个 Vite 插件,对 renderer 进行打包,生成一个 assets-render.tar.gz ,算出 sha256,生成一个 manifest.yml。

import { execSync } from "node:child_process"
import { createHash } from "node:crypto"
import fs from "node:fs/promises"
import path from "node:path"

import * as tar from "tar"
import type { Plugin } from "vite"

import { calculateMainHash } from "./generate-main-hash"

async function compressDirectory(sourceDir: string, outputFile: string) {
  await tar.c(
    {
      gzip: true,
      file: outputFile,
      cwd: sourceDir,
    },
    ["renderer"],
  )
}

function compressAndFingerprintPlugin(outDir: string): Plugin {
  return {
    name: "compress-and-fingerprint",
    apply: "build",
    async closeBundle() {
      const outputFile = path.join(outDir, "render-asset.tar.gz")
      const manifestFile = path.join(outDir, "manifest.yml")

      console.info("Compressing and fingerprinting...")
      // Compress the entire output directory
      await compressDirectory(outDir, outputFile)
      console.info("Compressing and fingerprinting", outDir, "done")

      // Calculate the file hash
      const fileBuffer = await fs.readFile(outputFile)
      const hashSum = createHash("sha256")
      hashSum.update(fileBuffer)
      const hex = hashSum.digest("hex")

      // Calculate main hash
      const mainHash = await calculateMainHash(path.resolve(process.cwd(), "apps/main"))

      // Get the current git tag version
      let version = "unknown"
      try {
        version = execSync("git describe --tags").toString().trim()
      } catch (error) {
        console.warn("Could not retrieve git tag version:", error)
      }

      // Write the manifest file
      const manifestContent = `
version: ${version.startsWith("v") ? version.slice(1) : version}
hash: ${hex}
mainHash: ${mainHash}
commit: ${execSync("git rev-parse HEAD").toString().trim()}
filename: ${path.basename(outputFile)}
`
      console.info("Writing manifest file", manifestContent)
      await fs.writeFile(manifestFile, manifestContent.trim())
    },
  }
}

export default compressAndFingerprintPlugin

一个典型的 minifest.yml 是这样的。

version: 0.2.6-nightly.20241209-12-gb22f3259
hash: d26f1b1e8def1461b39410d70ff10280e17a941ed5582702078b30397774e247
mainHash: 3a5737e82826d4aabcfec851dbec19f099880fe60a59bf59eb0f6fcc5e4fbfa2
commit: b22f32594c4bba16eaf64d1aa3906e2b8e56fd3e
filename: render-asset.tar.gz
  • version 代表这个热更所属的 renderer 对应的版本(并不是基座 app 的版本)
  • hash 是用来验证 gz 包文件指纹是否一致的
  • mainHash 是用于鉴定此热更包是否可用于当前的 app,如果和基座 app 的 mainHash 不一致则代表不可用
  • commit 记录当前的热更 git commit
  • filename 产生的 gz 文件名

Renderer 热更的流程:

App 启动,等待 mainWindow 加载完成,获取 GitHub latest Release 获取 manifest,对比当前的 commit 和 manifest 的 commit 是否一致,对比当前的 version 和 manifest 的 version 是否一致,对比当前的 mainHash 和 manifest 的 mainHash 是否一致。满足之后开始热更。

解压更新包,更新完成提示用户刷新页面,更新完成。

即使用户不刷新页面,下次启动自动加载新版 renderer 代码。

::: note

renderer 层的更新后的代码,不再使用内建的 asar 代码,而是加载下面的目录(典型)

目录位置:/Users/username/Library/Application Support/Follow/render (macOS)

:::

和正常发版一样,热更需要创建一个新的 Tag。Tag 会触发对 renderer 的单独构建。然后发布这两个文件到 release。

接下来我们来 main 中实现热更新的逻辑。

首先来实现 canUpdateRender 的逻辑:

export const canUpdateRender = async (): Promise<[CanUpdateRenderState, Manifest | null]> => {
  const manifest = await getLatestReleaseManifest()
  if (!manifest) return [CanUpdateRenderState.NETWORK_ERROR, null]

  const appSupport = mainHash === manifest.mainHash
  if (!appSupport) {
    logger.info("app not support, should trigger app force update, app version: ", appVersion)

    return [CanUpdateRenderState.APP_NOT_SUPPORT, null]
  }

  const isVersionEqual = appVersion === manifest.version
  if (isVersionEqual) {
    logger.info("version is equal, skip update")
    return [CanUpdateRenderState.NO_NEEDED, null]
  }
  const isCommitEqual = GIT_COMMIT_HASH === manifest.commit
  if (isCommitEqual) {
    logger.info("commit is equal, skip update")
    return [CanUpdateRenderState.NO_NEEDED, null]
  }

  const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
  const manifestExist = existsSync(manifestFilePath)

  const oldManifest: Manifest | null = manifestExist
    ? (load(readFileSync(manifestFilePath, "utf-8")) as Manifest)
    : null

  if (oldManifest) {
    if (oldManifest.version === manifest.version) {
      logger.info("manifest version is equal, skip update")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
    if (oldManifest.commit === manifest.commit) {
      logger.info("manifest commit is equal, skip update")
      return [CanUpdateRenderState.NO_NEEDED, null]
    }
  }
  return [CanUpdateRenderState.NEEDED, manifest]
}

这里比对了 mainHash 一致,commit 一致,version 一致,只有都满足条件才能进行热更。

热更资源的存放路径可以指定为:

export const HOTUPDATE_RENDER_ENTRY_DIR = path.resolve(app.getPath("userData"), "render")

const downloadRenderAsset = async (manifest: Manifest) => {
  hotUpdateDownloadTrack(manifest.version)
  const { filename } = manifest
  const url = await getFileDownloadUrl(filename)

  logger.info(`Downloading ${url}`)
  const res = await fetch(url)
  const arrayBuffer = await res.arrayBuffer()
  const buffer = Buffer.from(arrayBuffer)
  const filePath = path.resolve(downloadTempDir, filename)
  await mkdir(downloadTempDir, { recursive: true })
  await writeFile(filePath, buffer)

  const sha256 = createHash("sha256")
  sha256.update(buffer)
  const hash = sha256.digest("hex")
  if (hash !== manifest.hash) {
    logger.error("Hash mismatch", hash, manifest.hash)
    return false
  }
  return filePath
}

export const hotUpdateRender = async (manifest: Manifest) => {
  if (!appUpdaterConfig.enableRenderHotUpdate) return false

  if (!manifest) return false

  const filePath = await downloadRenderAsset(manifest)
  if (!filePath) return false

  // Extract the tar.gz file
  await mkdir(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true })
  await x({
    f: filePath,
    cwd: HOTUPDATE_RENDER_ENTRY_DIR,
  })

  // Rename `renderer` folder to `manifest.version`
  await rename(
    path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "renderer"),
    path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, manifest.version),
  )

  await writeFile(
    path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml"),
    JSON.stringify(manifest),
  )
  logger.info(`Hot update render success, update to ${manifest.version}`)
  hotUpdateRenderSuccessTrack(manifest.version)
  const mainWindow = getMainWindow()
  if (!mainWindow) return false
  const caller = callWindowExpose(mainWindow)
  caller.readyToUpdate()
  return true
}

export const getCurrentRenderManifest = () => {
  const manifestFilePath = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, "manifest.yml")
  const manifestExist = existsSync(manifestFilePath)
  if (!manifestExist) return null
  return load(readFileSync(manifestFilePath, "utf-8")) as Manifest
}
export const cleanupOldRender = async () => {
  const manifest = getCurrentRenderManifest()
  if (!manifest) {
    // Empty the directory
    await rm(HOTUPDATE_RENDER_ENTRY_DIR, { recursive: true, force: true })
    return
  }

  const currentRenderVersion = manifest.version
  // Clean all not current version
  const dirs = await readdir(HOTUPDATE_RENDER_ENTRY_DIR)
  for (const dir of dirs) {
    const isDir = (await stat(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir))).isDirectory()
    if (!isDir) continue
    if (dir === currentRenderVersion) continue
    await rm(path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, dir), { recursive: true, force: true })
  }
}

随后下载热更资源,然后解压到制定的路径。此时就成功了,可以提示用户刷新页面,也可以等待下一次打开 app,自动加载新资源。

从这里开始,app 就会存在两个版本号,一个是基座 app 的版本,另一个是更新后的 renderer 版本。

进一步,我们结合 app 的本身的更新(使用 Electron updater)。

const upgradeRenderIfNeeded = async () => {
  const [state, manifest] = await canUpdateRender()
  if (state === CanUpdateRenderState.NO_NEEDED) {
    return true
  }
  if (state === CanUpdateRenderState.NEEDED && manifest) {
    await hotUpdateRender(manifest)
    return true
  }
  return false
}
export const checkForAppUpdates = async () => {
  if (disabled || checkingUpdate) {
    return
  }

  checkingUpdate = true
  try {
    if (appUpdaterConfig.enableRenderHotUpdate) {
      const isRenderUpgraded = await upgradeRenderIfNeeded()
      if (isRenderUpgraded) {
        return
      }
    }
    return autoUpdater.checkForUpdates()
  } catch (e) {
    logger.error("Error checking for updates", e)
  } finally {
    checkingUpdate = false
  }
}

这里需要优先检查 renderer 的更新,再触发 app 的更新,如果命中 renderer 更新,则表示 app 更新不再需要。

修改 renderer 加载 index.html 的逻辑。

  • 当热更存在并合法,优先优先加载
  • 全无时,默认加载 app.asar 资源

const loadDynamicRenderEntry = () => {
  const manifest = getCurrentRenderManifest()
  if (!manifest) return
  // check main hash is equal to manifest.mainHash
  const appSupport = mainHash === manifest.mainHash
  if (!appSupport) return

  const currentRenderVersion = manifest.version
  const dir = path.resolve(HOTUPDATE_RENDER_ENTRY_DIR, currentRenderVersion)
  const entryFile = path.resolve(dir, "index.html")
  const entryFileExists = existsSync(entryFile)

  if (!entryFileExists) return
  return entryFile
}


// Production entry
const dynamicRenderEntry = loadDynamicRenderEntry()
logger.info("load dynamic render entry", dynamicRenderEntry)
const appLoadEntry = dynamicRenderEntry || path.resolve(__dirname, "../renderer/index.html")

window.loadFile(appLoadEntry, {
  hash: options?.extraPath,
})

通过 loadDynamicRenderEntry 去获取可用的热更资源。

后记

使用 renderer 热更,可以让更新更加无感,并无需重启 app,更新包也只要几 M 就行。如果 app 是一个重 web 并且不太需要经常修改 main 代码的,比较推荐这种方式。

https://github.com/RSSNext/Follow/blob/3499535b4091e3a82734c416528b0766e70f0b63/apps/main/src/updater/hot-updater.ts

看完了?说点什么呢

2024.11

2024年11月29日 14:01
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/183

工作与心理

转眼间,距离上次记事已经过去一个月之久。也是时候来记录一下流水账了。

最近这个月其实并没有发生什么特别值得记录的事。

昨天和公司新同事进行了 One-One,聊着聊着从工作转到生活,从同事聊到病友交流会,做这行感觉好一部分人都曾经或者当下忍受着焦虑抑郁之苦。回想距离上次病发已经是半年前了,而现如今入职新工作已经过去了半年。当时的担心害怕,在现在看来也不过这样,当下也并没有想的这么糟。这个行业就是一个非常不确定的因素,需要时刻准备着。而我最大的焦虑源,永远都是不够自信,过于杞人忧天且不付出行动。未来的路依然充满变数,何时才能克服恐惧,我也不知。

Follow 产品进展

说到工作,又过去了一个月,再来聊聊 Follow 最近有什么大的变化。首先是现在支持移动端的响应式设计了。通过 PWA 可以直接把 Follow 添加到桌面上,虽然没有原生 App 那么丝滑但已经很好用了。

Follow 从一开始的爆火和快速增长,到慢慢有了一些来自用户觉得不够好的声音,其实也是走过了一个互联网产品从 0 到 1 的必经之路。

从内测开始使用 Follow 已经三四个月了,参与开发也已经快两个月(虽然主要做的其实是 RSSHub 这一端和相关的一些功能模块),不论是作为 RSS 老用户还是开发者的角度来说,真的能感觉到 Follow 已经很棒了,尤其是作为一个从想法萌生到现在也不过半年的一个产品。

作为小团队的产品没办法一下子处理完所有 Bug 和一些功能需求,为了保障用户体验也只能借助邀请码逐步扩大用户规模,期间有大大小小的问题,但一直在努力迭代和改进了,开发群里也常常深夜还有很多讨论,希望大家能够给予更多耐心。

https://www.pseudoyu.com/zh/2024/11/29/weekly_review_20241129/

生活点滴

前段日子,去了一趟上海,参加了上海的疯狂星期四。感觉有点尬,此处无图。

最后,最近没有什么买的,未来一段时间内应该也不会买什么电子垃圾了。因为,买了辆 Model 3,之后要开始还贷了。

看完了?说点什么呢

Vercel 部署的单页应用(SPA)动态 Meta 标签实践

2024年11月3日 23:33
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/dynamic-meta-tags-for-spa-on-vercel

这篇文章是实现 Follow 的 Separate main application and external pages and SEO support 这个 PR 的一部分技术细节,由于其中的技术细节较多,所以这将是系列文中的一篇。

在上一篇文章在 Vercel 部署一个 OpenGraph Image 服务中,我们介绍了如何利用 Vercel 部署一个 Node.js 服务器。

在这篇文章中,我们会利用这个服务器来做更多的事。其中一件事就是优化 SPA 应用的 SEO,通过 meta 标签把我们生成的 OG Image 信息添加到 Meta 标签中。

然后我们还需要把这两个项目同时挂载到同一个域名上,利用 Vercel 的 Rewrite 实现。

使用 Node.js 服务器反代 HTML

初识 OpenGraph

一般的社交媒体会抓取站点的 HTML 源码,然后获取 meta 标签的内容展现不同的内容,往往这些抓取引擎并不具备 JS 渲染的能力,以此我们需要借助 Node.js 服务器反代 SPA 的 HTML,然后在 HTML 中根据 URL 的路径动态插入需要的 meta 信息。

OpenGraph 信息是社交媒体抓取数据最常见的一个类型,一个典型的 OpenGraph meta 标签如下所示:

><meta property="og:title" content="浮光掠影的日常 | 静かな森" /><meta
  property="og:description"
  content="又过了半个月之久。这些天发生了什么值得记录的呢。  一时脑热的兴趣  上个月底,国服炉石重新开服了。回想很早之前也玩过一段时间炉石,在大学的时候还把整个寝室带入了坑,而后来我沉迷代码又弃坑了。  这次重新开服福利还是不少的,送了去年一整年的所有卡,让我也重新上号领了一波福利。玩了几天之后慢慢熟悉了新"
/><meta
  property="og:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/><meta property="og:type" content="article" /><meta
  name="twitter:card"
  content="summary_large_image"
/><meta name="twitter:title" content="浮光掠影的日常" /><meta
  name="twitter:description"
  content="又过了半个月之久。这些天发生了什么值得记录的呢。  一时脑热的兴趣  上个月底,国服炉石重新开服了。回想很早之前也玩过一段时间炉石,在大学的时候还把整个寝室带入了坑,而后来我沉迷代码又弃坑了。  这次重新开服福利还是不少的,送了去年一整年的所有卡,让我也重新上号领了一波福利。玩了几天之后慢慢熟悉了新"
/><meta
  name="twitter:image"
  content="https://innei.in/og?data=%257B%2522type%2522%253A%2522note%2522%252C%2522nid%2522%253A%2522182%2522%257D"
/>

在 X 上的效果如下:

使用 Monorepo 分离 SPA 应用

在一个 SPA 应用中,往往并不是所有的路由都是需要做 SEO 的,往往只有几个需要被分享的页面才需要。例如在 Follow 应用中,只有 /share 路由底下的需要做 SEO 优化。

例如: https://app.follow.is/share/users/innei 动态插入的 meta 标签有:

现在我们来做分离项目的工作。在开始之前我们需要知道 SPA 应用是如何工作的。一个典型的由 Vite 编译后的 SPA 应用的目录结构如下:

dist
├── assets
│   ├── index.12345678.css
│   ├── index.12345678.js
│   └── index.12345678.js.map
├── index.html

其中 index.html 是我们的 SPA 应用的入口文件,浏览器在访问 / 路由时会加载这个文件,然后根据 JS 文件动态渲染页面。

我们只需要把这个文件使用 Node.js 进行反向代理,然后根据 URL 的路径动态插入 meta 标签即可。

为了分离 SPA 项目,我们需要再创建一个 SPA 应用,然后把需要做 SEO 的路由放到这个项目中。在这个过程中,我们或许会复制大量的共享组件,这或许也是需要改造的。利用 Monorepo 可以很好的解决这个问题。

例如在原先的项目中,apps/renderer 是一个完全体的 SPA 应用,现在我们创建一个新的 app 名为 server,目录位置 apps/server,这是一个反代服务器。创建一个用于存放前端代码的目录,例如 apps/server/client

我们复刻一个和 apps/renderer 相同的目录结构,然后把需要的路由放到这个项目中。把通用模块,例如组件和工具函数从 apps/renderer 中提取,抽离到 packages 目录中。这个过程可以是渐进式的。在重构当中,为了避免这个 commit 过大和停滞时间过长造成的大量的冲突,我们可以先通过复制代码把通用模块从 apps/renderer 中提取到 packages 中,但是不改动原先的代码的引用关系,在新的应用中使用 packages 的引用。例如,我们创建一个 packages/components 目录,然后把 apps/renderer/src/components 中的部分组件提取。

创建一个包的 package.json 文件,例如:

{
  "name": "@follow/components",
  "version": "0.0.1",
  "private": true,
  "sideEffects": false,
  "exports": {
    "./tailwind": "./assets/index.css",
    "./modules/*": "./src/modules/*",
    "./common/*": "./src/common/*",
    "./icons/*": "./src/icons/*",
    "./ui/*": "./src/ui/*",
    "./providers/*": "./src/providers/*",
    "./hooks/*": "./src/hooks/*",
    "./atoms/*": "./src/atoms/*",
    "./dayjs": "./src/utils/dayjs.ts",
    "./utils/*": "./src/utils/*"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "dependencies": {}
}

这个包并不需要去编译,所以我们可以直接导出源码,在这里,我们使用 exports 字段来指定导出的文件,对多个目录使用通配符导出。通用的样式也可以导出一个通用的 CSS 文件。例如我们使用 TailwindCSS。

@import './colors.css'; /* 自定义颜色 */
@import './tailwind.css'; /* TailwindCSS */
@import './font.css'; /* 自定义字体 */

@tailwind base;
@tailwind components;
@tailwind utilities; /* Other */

apps/server 中引用这个包:

pnpm i @follow/components@workspace:*

此时在 apps/server/client 中可以直接引用这个包来使用封装的组件:

例如:

import { Button } from '@follow/components/ui/button/index.js'

对了,如果你使用 TailwindCSS,可能还需要更改一下 content 字段。

export default resolveConfig({
  content: [
    './client/**/*.{ts,tsx}',
    './index.html',
    './node_modules/@follow/components/**/*.{ts,tsx}', // Add this line
  ],
})

提取组件和封装通用模块的过程是非常漫长的也是很痛苦的,在对 Follow 的改造中,我一共提取了 8 个包,移动和修改了近一万行代码。

因为我们之前都是复制的 apps/renderer 中的代码,所以在改造完成之后,我们还需要一次性修改 renderer 中所有的代码引用,例如 ~/components 改到 @follow/components,然后删除所有在 renderer 的已经被迁移的组件和代码。

Nodejs 服务器反代 HTML

这部分分为两个部分,一是处理开发环境下 Vite Dev Server 和 Nodejs 服务器的绑定,实现对 Vite Dev Server 产生的 HTML 进行修改并注入 meta 标签。第二是处理生产环境下编译产生的 index.html 文件。

首先来说开发环境下,我们这里使用 Fastify 做为 Nodejs 服务器举例。

我们可以通过下面的方式实现和 Vite Dev Server 绑定。

let globalVite: ViteDevServer
export const registerDevViteServer = async (app: FastifyInstance) => {
  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom',

    configFile: resolve(root, 'vite.config.mts'), // vite config 的路径
    envDir: root,
  })
  globalVite = vite

  // @ts-ignore
  app.use(vite.middlewares)
  return vite
}

在 App 初始化时期:

const app = Fastify({})

app.register(fastifyRequestContext)
await app.register(middie, {
  hook: 'onRequest',
})

if (isDev) {
  const devVite = require('./src/lib/dev-vite')
  await devVite.registerDevViteServer(app)
}

创建一个用于处理 HTML 的路由:

import { parseHTML } from 'linkedom'

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'), // vite dev 使用 index.html 路径
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template) // 使用 vite 去转换
    const { document } = parseHTML(template)

    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

这样我们就实现了 HTML 的反代。

动态插入 Meta 标签

在上面的基础上,我们对 * 路由稍作修改:

app.get('*', async (req, reply) => {
  const url = req.originalUrl

  const root = resolve(__dirname, '../..')

  const vite = require('../lib/dev-vite').getViteServer()
  try {
    let template = readFileSync(
      path.resolve(root, vite.config.root, 'index.html'),
      'utf-8',
    )
    template = await vite.transformIndexHtml(url, template)
    const { document } = parseHTML(template)
    await injectMetaToTemplate(document, req, reply) // 在这里进行对 HTML 注入 meta 标签 // [!code ++]
    reply.type('text/html')
    reply.send(document.toString())
  } catch (e) {
    vite.ssrFixStacktrace(e)
    reply.code(500).send(e)
  }
})

定义 Meta 的类型:

interface MetaTagdata {
  type: 'meta'
  property: string
  content: string
}

interface MetaOpenGraph {
  type: 'openGraph'
  title: string
  description?: string
  image?: string | null
}

interface MetaTitle {
  type: 'title'
  title: string
}

export type MetaTag = MetaTagdata | MetaOpenGraph | MetaTitle

实现 injectMetaToTemplate 函数:

async function injectMetaToTemplate(
  document: Document,
  req: FastifyRequest,
  res: FastifyReply,
) {
  const injectMetadata = await injectMetaHandler(req, res).catch((err) => {
    // 在 injectMetadata 中根据 URL 的路径处理不同的 meta 标签注入
    if (isDev) {
      throw err
    }
    return []
  })

  if (!injectMetadata) {
    return document
  }

  for (const meta of injectMetadata) {
    switch (meta.type) {
      case 'openGraph': {
        const $metaArray = buildSeoMetaTags(document, { openGraph: meta })
        for (const $meta of $metaArray) {
          document.head.append($meta)
        }
        break
      }
      case 'meta': {
        const $meta = document.createElement('meta')
        $meta.setAttribute('property', meta.property)
        $meta.setAttribute('content', xss(meta.content))
        document.head.append($meta)
        break
      }
      case 'title': {
        if (meta.title) {
          const $title = document.querySelector('title')
          if ($title) {
            $title.textContent = `${xss(meta.title)} | Follow`
          } else {
            const $head = document.querySelector('head')
            if ($head) {
              const $title = document.createElement('title')
              $title.textContent = `${xss(meta.title)} | Follow`
              $head.append($title)
            }
          }
        }
        break
      }
    }
  }

  return document
}

import xss from 'xss'

export function buildSeoMetaTags(
  document: Document,
  configs: {
    openGraph: {
      title: string
      description?: string
      image?: string | null
    }
  },
) {
  const openGraph = {
    title: xss(configs.openGraph.title),
    description: xss(configs.openGraph.description ?? ''),
    image: xss(configs.openGraph.image ?? ''),
  }

  const createMeta = (property: string, content: string) => {
    const $meta = document.createElement('meta')
    $meta.setAttribute('property', property)
    $meta.setAttribute('content', content)
    return $meta
  }

  return [
    createMeta('og:title', openGraph.title),
    createMeta('og:description', openGraph.description),
    createMeta('og:image', openGraph.image),
    createMeta('twitter:card', 'summary_large_image'),
    createMeta('twitter:title', openGraph.title),
    createMeta('twitter:description', openGraph.description),
    createMeta('twitter:image', openGraph.image),
  ]
}

实现 injectMetaHandler 函数:

import { match } from 'path-to-regexp'

export async function injectMetaHandler(
  req: FastifyRequest,
  res: FastifyReply,
): Promise<MetaTag[]> {
  const apiClient = createApiClient()
  const upstreamOrigin = req.requestContext.get('upstreamOrigin')
  const url = req.originalUrl

  for (const [pattern, handler] of Object.entries(importer)) {
    const matchFn = match(pattern, { decode: decodeURIComponent })
    const result = matchFn(url)

    if (result) {
      const parsedUrl = new URL(url, upstreamOrigin)

      return await handler({
        // 可以按需在这里传递上下文
        params: result.params as Record<string, string>,
        url: parsedUrl,
        req,
        apiClient,
        origin: upstreamOrigin || '',
        setStatus(status) {
          res.status(status)
        },
        setStatusText(statusText) {
          res.raw.statusMessage = statusText
        },
        throwError(status, message) {
          throw new MetaError(status, message)
        },
      })
    }
  }

  return []
}

injectMetaHandler 中,我们 path-to-regexp 匹配类似 /share/feeds/:id 的路径,然后从 importer map 中找到对应的处理函数,然后返回一个 MetaTag 数组。

importer map 应该是一个根据 SPA 应用的路由自动生成的表,例如:

import i1 from '../client/pages/(main)/share/feeds/[id]/metadata'
import i2 from '../client/pages/(main)/share/lists/[id]/metadata'
import i0 from '../client/pages/(main)/share/users/[id]/metadata'

export default {
  '/share/users/:id': i0,
  '/share/feeds/:id': i1,
  '/share/lists/:id': i2,
}

在 Follow 中,SPA 的路由定义是根据文件系统树生成的,所以我们可以根据这个特征编写一个 helper watcher。

import * as fs from 'node:fs/promises'
import * as path from 'node:path'
import { dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

import chokidar from 'chokidar'
import { glob } from 'glob'

const __dirname = dirname(fileURLToPath(import.meta.url))

async function generateMetaMap() {
  const files = await glob('./client/pages/(main)/**/metadata.ts', {
    cwd: path.resolve(__dirname, '..'),
  })

  const imports: string[] = []
  const routes: Record<string, string> = {}

  files.forEach((file, index) => {
    const routePath = file
      .replace('client/pages/(main)', '')
      .replace('/metadata.ts', '')
      .replaceAll(/\[([^\]]+)\]/g, ':$1')

    const importName = `i${index}`
    imports.push(`import ${importName} from "../${file.replace('.ts', '')}"`)
    routes[routePath] = importName
  })

  const content =
    '// This file is generated by `pnpm run meta`\n' +
    `${imports.join('\n')}\n
export default {
${Object.entries(routes)
  .map(([route, imp]) => `  "${route}": ${imp},`)
  .join('\n')}
}
`

  const originalContent = await fs.readFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    'utf-8',
  )
  if (originalContent === content) return
  await fs.writeFile(
    path.resolve(__dirname, '../src/meta-handler.map.ts'),
    content,
    'utf-8',
  )
  console.info('Meta map generated successfully!')
}

async function watch() {
  const watchPath = path.resolve(__dirname, '..', './client/pages/(main)')
  console.info('Watching metadata files...')

  await generateMetaMap()
  const watcher = chokidar.watch(watchPath, {
    ignoreInitial: false,
  })

  watcher.on('add', () => {
    console.info('Metadata file added/changed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('unlink', () => {
    console.info('Metadata file removed, regenerating map...')
    generateMetaMap()
  })

  watcher.on('change', () => {
    console.info('Metadata file changed, regenerating map...')
    generateMetaMap()
  })

  process.on('SIGINT', () => {
    watcher.close()
    process.exit(0)
  })
}

if (process.argv.includes('--watch')) {
  watch().catch(console.error)
} else {
  generateMetaMap().catch(console.error)
}

现在在路由下创建一个 metadata.ts 文件,导出一个定义 metadata 的函数。

import { defineMetadata } from '~/meta-handler'

export default defineMetadata(
  async ({ params, apiClient, origin, throwError }) => {
    const listId = params.id
    const list = await apiClient.lists.$get({ query: { listId } })

    const { title, description } = list.data.list
    return [
      {
        type: 'openGraph',
        title: title || '',
        description: description || '',
        image: `${origin}/og/list/${listId}`,
      },
      {
        type: 'title',
        title: title || '',
      },
    ]
  },
)

至此为止,在 dev 环境下,我们已经实现了对 Vite Dev Server 的反代,并且动态插入 meta 标签。接下来是处理生产环境的差异。

生产环境下的 HTML 反代

在生产环境下,我们需要反代的是编译后的 HTML 文件。

app.get('*', async (req, reply) => {
  const template = readFileSync(
    path.resolve(root, '../dist/index.html'), // 这里是编译后的 HTML 文件
    'utf-8',
  )
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

通过直接取代文件系统的方式在 Vercel 这类平台可能并不好使,因为编译的产物并不会在 API 调用环境中,因此在部署到 Vercel 上会出现找不到这个文件。

我们可以通过直接打包编译后 HTML 字符串的方式来解决这个问题。

import { mkdirSync } from 'node:fs'
import fs from 'node:fs/promises'
import path from 'node:path'

mkdirSync(path.join(__dirname, '../.generated'), { recursive: true })

async function generateIndexHtmlData() {
  const indexHtml = await fs.readFile(
    path.join(__dirname, '../dist/index.html'),
    'utf-8',
  )
  await fs.writeFile(
    path.join(__dirname, '../.generated/index.template.ts'),
    `export default ${JSON.stringify(indexHtml)}`,
  )
}

async function main() {
  await generateIndexHtmlData()
}

main()

上面的脚本中在 SPA 项目编译后,我们把编译后的 HTML 文件读取,然后写入到一个 .generated 目录下,然后导出 HTML 字符串。

修改生产环境下的反代路由逻辑。

app.get('*', async (req, reply) => {
  const template = require('../../.generated/index.template').default // [!code highlight]
  const { document } = parseHTML(template)
  await injectMetaToTemplate(document, req, reply)

  reply.type('text/html')
  reply.send(document.toString())
})

然后我们修改 build 流程:

cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsup

关于使用 tsup 编译服务端,在 在 Vercel 部署一个 OpenGraph Image 服务 中有介绍。

现在有了两个不同环境的处理逻辑,我们可以做下判断:

export const globalRoute = isDev ? devHandler : prodHandler

部署到 Vercel

现在我们可以部署到 Vercel 上,我们需要实现在同一个域名上挂载两个应用。我们的主应用就是原先那个 app,然后我们需要在 Vercel 上创建一个新的应用,这个应用是用来反代 HTML 的是一个 Node.js 服务。

现在我们称原先的 app 为 app1,新的应用为 app2。

我们需要实现的 URL 路由规则是:

/share/* -> app2
/* -> app1

Not support render this content in RSS render

在 app2 中创建或修改 vercel.json

{
  "rewrites": [
    {
      "source": "/((?!external-dist|dist-external).*)", // 把所有请求全部重写到 Vercel 的 api route,关于 api 是什么可以在 ![在 Vercel 部署一个 OpenGraph Image 服务](https://innei.in/posts/tech/deploy-an-opengraph-image-service-on-vercel) 中找到
      "destination": "/api"
    }
  ]
}

在 app1 中创建或修改 vercel.json

{
  "rewrites": [
    {
      "source": "/share/:path*",
      "destination": "https://<domain>/share/:path*" // 修改地址
    },
    {
      "source": "/og/:path*",
      "destination": "https://<domain>/og/:path*"
    },
    {
      "source": "/((?!assets|vendor/).*)",
      "destination": "/index.html"
    }
  ],

  "headers": []
}

经过这样的修改之后访问 /share 的路径可以正确重写到 app2,但是 app2 的资源文件全都 404 了。由于 app1 的编译产物也在 assets 路径下,为了两者不冲突,我们给 app2 放一个别的路径,例如 dist-external

修改 app2 的 vite.config.ts

import { resolve } from 'node:path'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

export default () => {
  return defineConfig({
    build: {
      rollupOptions: {
        output: {
          assetFileNames: 'dist-external/[name].[hash].[ext]', // [!code ++]
          chunkFileNames: 'dist-external/[name].[hash].js', // [!code ++]
          entryFileNames: 'dist-external/[name].[hash].js', // [!code ++]
        },
      },
    },
    plugins: [react()],
  })
}

在 app1 中修改 vercel.json:

{
  "rewrites": [
    {
      "source": "/dist-external/:path*", // [!code ++]
      "destination": "https://<domain>/dist-external/:path*" // [!code ++]
    },
    {
      "source": "/((?!assets|vendor|locales|dist-external/).*)", // [!code highlight]
      "destination": "/index.html"
    }
  ]
}

由于篇幅过长,其他的细节请听以后分解。

看完了?说点什么呢

浮光掠影的日常

2024年10月27日 17:42
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/182

又过了半个月之久。这些天发生了什么值得记录的呢。

一时脑热的兴趣

上个月底,国服炉石重新开服了。回想很早之前也玩过一段时间炉石,在大学的时候还把整个寝室带入了坑,而后来我沉迷代码又弃坑了。

这次重新开服福利还是不少的,送了去年一整年的所有卡,让我也重新上号领了一波福利。玩了几天之后慢慢熟悉了新卡组新环境,组了几套有意思卡组,玩起来也有点意思。

本想着能在床上躺着玩,拿出了我曾经的 iPad 6,已经完全带不动这个游戏了,总是闪退。然后就去收了一个推友的二手 iPad Pro,12.9 寸的搓炉石就是爽。玩个游戏也算是带动经济了。

最近又买了啥

最近把家里的电子垃圾都卖给转转了,卖了一千多块。

转手又在转转上收了两个 HomePod mini 用来组家庭影院。

现在我的 Apple 设备清理到只有这些了。

糟糕的 1024

上周的 1024 碰巧也是 Follow 的公测发版日。

但是我们的经历的可谓是一波未平一波又起。由于没有做压力测试,随着流量的增大,最后还是没有顶住压力。凌晨的时候链也出问题了,早上开始请求也非常的慢,真是苦了我们的运维小哥,据说一晚被叫醒两次。白天时候刚刚通过扩容安顿了这家,下一个服务又出问题了...

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

近期需要关注的

Next.js 15 还是发布了,赶在 Nextjs Conf 之前。嗯,React 18 还是没发布正式版,最终它还是在正式版中依赖了 rc 版本的 React,也是挺抽象的。

我尝试把本站的 Next.js 版本升级了,但是失败了。或许我只能永远停留在 14.2.8 版本了,如果没有修正打包问题的话,连 ^14.2.8 都别想了,这真是一个非常糟糕的框架,每个小版本的更新总是在做一些非常离奇的改动导致生产中出现各种奇怪的问题。

我不止一次在公开场合抨击 Next.js 的不作为,但是在 Nextjs Conf 上我居然上镜了,真是有够抽象。

但是不管怎么样,Next.js 在 React 的元框架中仍是不可撼动的地位,真是悲哀。

看完了?说点什么呢

在 Vercel 部署一个 OpenGraph Image 服务

2024年10月20日 00:30
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/deploy-an-opengraph-image-service-on-vercel

这篇文章是实现 FollowSeparate main application and external pages and SEO support 这个 PR 的一部分技术细节,由于其中的技术细节较多,所以这将是系列文中的一篇。

用过 Next.js 的同学应该都知道,Next.js 自带了 ImageResponse 方法,可以用来生成 OpenGraph Image。

如果我们服务端框架不使用 Next.js,那么我们也可以使用 Vercel 提供的 @vercel/og 或者 satori 库来生成 OpenGraph Image。

vercel/og 是对 satori 的封装,使用起来更简单。但是这里我们使用 satori 来实现。

这一篇文章会介绍如何使用 satori 生成 OpenGraph Image,如何处理远程图片,字体,然后如何正确编译,最后部署到 Vercel 上。

初始化项目

起步

我们使用 Fastify 作为基座服务,其中一个接口去处理 OpenGraph Image 的生成。

npm i fastify dotenv

创建一个 index.ts 文件,写入以下内容:

const isVercel = process.env.VERCEL === '1'
export const createApp = async () => {
  const app = Fastify({})

  return app
}

if (!isVercel) {
  // 如果不是 Vercel 环境,则开启一个常驻服务
  createApp().then(async (app) => {
    await app.listen({ port: 2234 })
    console.info('Server is running on http://localhost:2234')
  })
}

创建 lib/load-env.ts 文件,加载环境变量:

import { resolve } from 'node:path'

import { config } from 'dotenv'

config({
  path: resolve(__dirname, '../../.env'),
})
注意此项目不要使用 ESM,在 package.json 中不需要设置 type: module,否则后续部署到 Vercel 会可能会出现不可预见的问题。

安装 tsx 使用 tsx watch index.ts 启动项目,方便调试。

路由初始化

我们使用 /og 路由处理 OpenGraph Image。

首先安装 satori 库:

npm i satori @resvg/resvg-js

创建 routes/og.ts 文件,写入以下内容:

import { FastifyInstance } from 'fastify'

export const ogRoute = (app: FastifyInstance) => {
  app.get('/og', async (req, reply) => {})
}

index.ts 中引入:

import { ogRoute } from './routes/og'

ogRoute(app)

封装 satori

satori 没有 @vercel/og 那么方便,需要我们自己再次封装,比如引入字体,生成 PNG 图片等。

引入字体

如果 OG Image 中存在字符,我们首先需要引入至少一种字体作为默认字体,如果需要显示中文,则需要引入中文字体。

这里我们使用小赖字体。安装字体:

npm i kose-font

创建一个 lib/log/font.ts

const koseFontPath = require.resolve('kose-font')
const koseFontData = fs.readFileSync(koseFontPath)

fontsData.push({
  name: 'Kose',
  data: koseFontData,
  weight: 400,
  style: 'normal' as 'italic' | 'normal',
})

export default fontsData

编写一个 renderToImage 方法,使用 resvg 生成 PNG 图片:

import { Resvg } from '@resvg/resvg-js'
import type { ReactElement } from 'react'
import type { SatoriOptions } from 'satori'
import satori from 'satori'

import fonts from './fonts'

export async function renderToImage(
  node: ReactElement,
  options: {
    width?: number
    height: number
    debug?: boolean
    fonts?: SatoriOptions['fonts']
  },
) {
  const svg = await satori(node, {
    ...options,
    fonts: options.fonts || fonts,
  })

  const w = new Resvg(svg)
  const image = w.render().asPng()

  return {
    image,
    contentType: 'image/png',
  }
}

要使用 JSX 的方式去表达 OG Image,还是需要安装 react 的。

npm i react

现在可以在 routes/og.ts 中使用 renderToImage 方法了。

import { FastifyInstance } from 'fastify'

import { renderToImage } from '../lib/og/render-to-image'

export const ogRoute = (app: FastifyInstance) => {
  app.get('/og', async (req, reply) => {
    const imageRes = await renderToImage(
      <div
        style={{
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          width: '100%',
          height: '100%',
        }}
      >
        <h1>你好,世界</h1>
      </div>,
      {
        width: 1200,
        height: 600,
      },
    )

    const stream = new Readable({
      read() {
        this.push(imageRes.image)
        this.push(null)
      },
    })

    reply.type(imageRes.contentType).headers({
      // 设置 CDN 缓存以减少计算资源的开销
      'Cache-Control':
        'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
      'Cloudflare-CDN-Cache-Control':
        'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
      'CDN-Cache-Control':
        'max-age=3600, s-maxage=3600, stale-while-revalidate=600',
    })
    return reply.send(stream)
  })
}

效果

引入图片

@vercel/og 不一样的是,在 satori 中的 <img /> 不接受传入 arrayBuffer 作为 src,只接受一个合法的可达的图片(非 SVG)的链接或者 base64 如果图片不可达或者图片格式不支持,则会导致 OG Image 生成失败,导致报错。

为了保证链接合法性和图片的可访问性,我们可以封装一个方法专门去获取图片,如果图片不可达则使用 fallback 图片。

async function getImageBase64(image: string | null | undefined) {
  if (!image) {
    return null
  }

  const url = new URL(image)
  return await fetch(image, {
    headers: {
      'User-Agent':
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
      Referer: url.origin,
    },
  }).then(async (res) => {
    const isImage = res.headers.get('content-type')?.startsWith('image/')
    if (isImage) {
      const arrayBuffer = await res.arrayBuffer()

      return `data:${res.headers.get('content-type')};base64,${Buffer.from(
        arrayBuffer,
      ).toString('base64')}`
    }
    return null
  })
}

上面的方法会首先去访问图片,如果图片不可达,则返回 null。如果链接可达,并且是合法的图片则编码为 base64 返回,使用这种方法还可以正确渲染 SVG 图片。

const image = await getImageBase64('https://innei.in/innei.svg')

const FALLBACK_IMAGE_BASE64 = 'data:image/svg+xml;base64,...'

await renderToImage(
  <div
    style={{
      display: 'flex',
      alignItems: 'center',
      justifyContent: 'center',
      width: '100%',
      flexDirection: 'column',
      gap: '1rem',
      height: '100%',
    }}
  >
    <img
      src={image || FALLBACK_IMAGE_BASE64}
      style={{ width: 256, height: 256 }}
    />
    <h1>Innei</h1>
  </div>,
  {
    width: 1200,
    height: 600,
  },
)

图片效果

部署到 Vercel

预备知识

Vercel 是一个 Serverless 平台,并不支持常驻服务,所以需要使用 Vercel 提供的 API 路由来部署。

开始之前,我们想要了解 Vercel API 是如何工作的。

当项目中存在 api 目录,Vercel 在构建项目时会自动识别并编译 api 目录中的文件,然后挂载到 /api 路由下。

在项目构建时,Vercel 会优先执行你设置的 build 指令,然后再使用 @vercel/node 去编译 api 目录中的文件。

编写 API 路由

首先我们需要在项目根目录新建文件夹 api,然后在里面新建 index.ts 文件,写入以下内容:

const { createApp } = require('../dist/server/index.js') // 这里使用编译产物,防止 Vercel 编译 ts 文件时的错误

module.exports = async function handler(req: any, res: any) {
  const app = await createApp()
  await app.ready()
  app.server.emit('request', req, res)
}

dist 是项目编译后的文件夹,index.js 是项目入口文件。这个文件在没有编译的情况下是不存在的,所以需要我们手动编译。

安装 tsup 去编译:

npm i tsup

写入 tsup 配置:

import { defineConfig } from 'tsup'

export default defineConfig(() => {
  return {
    entry: ['index.ts'],
    outDir: 'dist/server',
    splitting: false,
    clean: true,
    format: ['cjs'],
    treeshake: true,
  }
})

现在就可以使用 tsup 去编译项目了:

tsup

Vercel 创建项目

我们可以使用 Vercel CLI 在本地构建看看输出文件,开始之前首先需要在 Vercel 上创建一个项目,并且已经和 Vercel CLI 关联。

在 Vercel 中项目中,build 指令填写:

pnpm tsup

现在可以使用 vercel build 预览 Vercel 的构建结果了。

$ tree -L 2 .vercel
.vercel
├── README.txt
├── output
│   ├── builds.json
│   ├── config.json
│   ├── functions
│   └── static
└── project.json

解决字体文件缺失

output/functions 目录下会 API 编译后的文件。

$ tree -L 3
.
└── api
    └── index.func
        ├── apps
        └── node_modules

继续追踪 node_modules 目录,会发现缺少了 kose-font 这个依赖。导致 OG 没有找到字体报错。

回想一下,我们是通过 require.resolve 去获取字体文件的,经过 tsup 编译之后变成了 __require.resolve(), __requiretsup 转换后的 require 导致 @vercel/node 在编译的时候没有正确识别这个需要的依赖,从而被舍弃了。

那么,只要我们注入原始的 require.resolve('kose-font') 就可以解决这个问题了。为了保证 tsup 不转换,我们可以提前注入原始代码块。

修改 tsup.config.ts 文件:

import { defineConfig } from 'tsup'

export default defineConfig(() => {
  return {
    entry: ['index.ts'],
    outDir: 'dist/server',
    splitting: false,
    clean: true,
    format: ['cjs'],
    treeshake: true,
    banner: { //  [!code ++]
      js: `try { require.resolve('kose-font') } catch {}`, // [!code ++]
    }, //  [!code ++]
  }
})

kose-font 被打包了

是因为这里注入了原始 require.resolve 代码

现在,项目可以部署到 Vercel 了。

API 重写

Vercel API 默认都是以 /api 作为前缀,而我们希望以 /og 作为前缀,所以需要重写 API 路由。

根目录建立 vercel.json 文件,写入以下内容:

{
  "rewrites": [
    {
      "source": "/(.*)",
      "destination": "/api"
    }
  ]
}

这样,所有的请求都会被重写到 /api 路由下。

大功告成。

参考

https://github.com/RSSNext/Follow/pull/987

看完了?说点什么呢

刹那

2024年10月5日 23:12
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/181

又过了半个月之久。这些天发生了什么值得记录的呢。

黑客松

上周公司举行了内部的黑客松,时间为:

Sep 20, 2024 00:00 GMT – Sep 27, 2024 00:00 GMT

刚好是过了中秋节,第一次参加任何形式的黑客松,也是一次和同事友好交流的机会,就参加了。我们小组成员都在浙江沪地区,有着天然的沟通优势。

前期讨论做什么花的时间还是挺多的,直到最后几天才开始正式写代码。

在 Deadline 前三天,我去了杭州,借宿在同事家,开始两天的肝码。第二天刚好是疯狂星期四,去良渚体验了一番,由于上次去良渚刚好赶上国庆假期,那个时候几乎没有人在写码。

这次,我们小组成员三人开始抱团写码了,时间还是非常肝的,两天需要完成一个 MVP。

其实在 X 已经透露过了,我们做的是一个 Follow 周边项目,一个 Chrome 扩展,目前支持的功能有:

  • Discover Trending(排行榜)
  • Comment(评论)
  • Star Timeline (收藏时间线)

:::masonry

:::

目前所有的功能都通过注入到 Follow Webapp 中修改的,所以效果肯定没有在 Follow 中直接实现的好。但是这种方法完全和 app 分离,不会和原项目有耦合关系,也是一种功能探索的方式。

以上的功能对 Chrome 扩展来说,还不是 1/10 的特征。毕竟这也是一个小小的原型而已,我们已经构想未来需要实现的一些功能,在此可以透露:

  • 发现当前浏览的网站是否在 Follow 中存在对应的 Feed,透过订阅 Feed 发现用户圈的社交网。
  • 嵌入自己认领的 Feed 评论到自己的博客中,可以利用外嵌式评论同步显示在博客和 Follow 中。

对了,点击下面的链接即可试用:

https://github.com/RSSNext/Followise

真香的季节

又到了一年一度的苹果丰收的季节,一群人喊着 iPhone 年年挤牙膏不要买,确实真香定理。我也是。

看着 PDD 优惠 600 的百亿补贴,我心动了。已入手,初音色。

← iPhone 15 / iPhone 16 →

试驾 Model 3

好久没开过车的我,我试驾了下 Model 3。不出意外的话就要出意外了。吓得我脚都软了。在掉头的时候,倒车完事之后,忘了屏幕挂挡,直接踩电门了,差点撞到后车,差点一点点就三连撞了。还好及时刹车。

屏幕挂挡真不喜欢。

看完了?说点什么呢

浅谈 Follow 中的设计理念

2024年9月17日 00:41
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/design/design-concepts-in-follow-app

作为 Follow 的核心开发者,经过了三个多月的打磨,或者可以在这里讲讲其中的一些设计理念。

https://github.com/RSSNext/follow

图标转换

在 Follow 中,我们使用了大量图标,在一些场景下,状态的切换会伴随着图标的切换。

为了让图标转换的过程不显得突兀,我们在合适的地方选用不同的方式进行转换:

  1. 在「热点」中,圆形图标的状态转换:

  1. 在分组已读中,采用缩放的方式进行转换,这是两组不同类型的图标。

同样的也应用在很多地方的 CopyButton 中,比如:

随便再提一个小细节,你上面看到的在代码块中的 CopyButton 的背景色是由当前代码块的决定的。上图的代码为 TypeScript 因此它是深青色的。

  1. 除此之外还有根据透明度进行转换的,目前场景不多。只用在 Feed 边栏 SortButton 中。

Peek Modal

Peek 是一种很好用的预览的交互方式。你可以不用转到原文就能透过 Modal 预览到原文,完成之后也可以很方便的跳转到原文或者选择回退。

这个交互应用在 AI Summary 提供的「热点」模块中。

除此之外的,在社交媒体中,Peek 的方式有所不同,由于社交媒体的信息量较少,则采用了通知式预览。

过渡原则:从何而来,到何而去

在 Follow 中,我们希望给用户带来一种「优雅」的体验,这种体验来源于我们对细节的把控。

在动画的过渡上,我们希望做到「从何而来,到何而去」。

例如 Modal 的展现形式中,既然是从微微弹跳中出现,就从那里回去。

你可以在实际的 Demo 中体验:

https://rc-modal.pages.dev

合理运用动效的前提是使用弹性(Spring)动画,只有越符合物理世界的运动规律,才能给人以流畅自然的感觉。

在 Follow 中,你能看到非常多的例子。这些都是得益于 Framer Motion 优秀的动画库在背后的支撑。

层叠式模态框

很多时候,页面上会出现多个模态框,在模态框中唤出的模态框,会在当前模态框之上。如何处理这种层叠关系,并且又让用户知道当前处于哪一个层级,同时下层的内容也能被感知。

我们使用了底层模态框的微微缩放,来表示层级的变化。

同时,层叠的模态框下层内容会被遮挡,所以我们还应用了可拖动的范围,类似于桌面型应用的对话框。

类似的,在设置界面,模态框可以变形为窗口,不仅可以拖动,还可以调整大小。此交互更加利于在修改设置之后看到 UI 的变化。

减弱高度变化带来的突兀感

为了减少内容高度变化带来的突兀感,在 Follow 中大量使用了 AutoResize 组件,通过对高度的动画,来减少页面抖动的感知。

类似的,在 Tab 的切换中:

又比如「热点」:

更多的物理效果

上面提到了,运用弹性动画,可以让人感觉到很流畅。

不只在动画中,在其他地方也有运用。例如在切换瞄点时的页面滚动。

¡先快后慢的柔和滚动

Not support render this content in RSS render

减弱动态效果

过渡动画虽好,但是运用大量动画也会带来设备的续航问题。再或者,就有人不喜欢过渡的动画。

在减弱动态效果中,大部分动画的位移都变成了透明度转换。

好了,今天暂时就先聊到这里,下次继续分享一些细节吧。

对了,很多设计细节从 Shiro 中来,大家也可以关注一下哦。

https://github.com/Innei/Shiro

(这篇文章中隐藏了两个 Follow 的邀请码,你能找到它吗)

看完了?说点什么呢

React i18n CSR 最佳实践

2024年9月15日 15:19
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/React-i18n-CSR-best-practices

最近,社区又开始给 Follow 上强度了,搞起了 i18n 工作。

开始之前有一个完善的 i18n 基建才是硬道理。我们选择 react-i18next。

接下来,我们会由浅入深去配置一个完善的 i18n 基建。

基础配置

npm install react-i18next i18next

建立一个 i18n 配置文件,比如 i18n.ts

import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'

import en from '@/locales/en.json'
import zhCN from '@/locales/zh_CN.json'

i18next.use(initReactI18next).init({
  lng: 'zh',
  fallbackLng: 'en',
  resources: {
    en: {
      translation: en,
    },
    zh: {
      translation: zhCN,
    },
  },
})

随后在入口文件中引入。

import './i18n'

那么这样就可以在项目中使用 i18n 了。

import { useTranslation } from 'react-i18next'

const { t } = useTranslation()

解决 TypeScript 类型问题

上面的代码虽然可以正常工作,但是在 TypeScript 中,你得不到任何类型检查以及智能提示。

那么,我们希望可以有一个类型安全的写法。

我们按照官网的推荐做法,可以把 resources 放到 @types 中,然后建立 i18next.d.ts 文件。

import en from '@/locales/en.json'
import lang_en from '@/locales/modules/languages/en.json'
import lang_zhCN from '@/locales/modules/languages/zh_CN.json'
import zhCN from '@/locales/zh_CN.json'

const resources = {
  en: {
    translation: en,
    lang: lang_en,
  },
  zh_CN: {
    translation: zhCN,
    lang: lang_zhCN,
  },
}
export default resources
import type resources from './resources'

declare module 'i18next' {
  interface CustomTypeOptions {
    resources: (typeof resources)['en']
    defaultNS: 'translation'
  }
}

然后修改 i18n.ts 文件。

import i18next from 'i18next'
import { initReactI18next } from 'react-i18next'

import resources from './@types/resources'

export const defaultNS = 'translation'
export const fallbackLanguage = 'en'
export const initI18n = () => {
  i18next.use(initReactI18next).init({
    lng: language,
    fallbackLng: fallbackLanguage,
    defaultNS,
    ns: [defaultNS],

    resources,
  })
}

那么现在就有类型提示。

分离 namespace

当我们项目变得越来越大,我们就会发现,如果把所有的文字都放在一个文件里,会非常难维护。因此我们需要把文字拆分到不同的文件里。也就是 namespace。

在 Follow 中,目前为止,一共拆分了以下几个 namespace:

  • app 应用相关
  • lang 语言
  • external 外部页面
  • settings 设置
  • shortcuts 快捷键
  • common 通用

目录结构如下:

. locales
├── app
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── common
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── external
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── lang
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
├── settings
│   ├── en.json
│   ├── zh-CN.json
│   └── zh-TW.json
└── shortcuts
    ├── en.json
    ├── zh-CN.json
    └── zh-TW.json

这样拆分之后,我们只需要在上面的 resources.d.ts 中引入所有的语言文件即可。

import en from '@/locales/en.json'
import lang_en from '@/locales/modules/languages/en.json'
import lang_zhCN from '@/locales/modules/languages/zh_CN.json'
import lang_zhTW from '@/locales/modules/languages/zh_TW.json'
import settings_en from '@/locales/modules/settings/en.json'
import settings_zhCN from '@/locales/modules/settings/zh_CN.json'
import shortcuts_en from '@/locales/modules/shortcuts/en.json'
import shortcuts_zhCN from '@/locales/modules/shortcuts/zh_CN.json'
import common_en from '@/locales/modules/common/en.json'
import common_zhCN from '@/locales/modules/common/zh_CN.json'
import external_en from '@/locales/modules/external/en.json'
import external_zhCN from '@/locales/modules/external/zh_CN.json'
import external_zhTW from '@/locales/modules/external/zh_TW.json'
const resources = {
  en: {
    translation: en,
    lang: lang_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
    common: common_en,
    external: external_en,
  },
  zh_CN: {
    translation: zhCN,
    lang: lang_zhCN,
    settings: settings_zhCN,
    shortcuts: shortcuts_zhCN,
    common: common_zhCN,
    external: external_zhCN,
  },

  // 其他语言
  zh_TW: {
    translation: zhTW,
    lang: lang_zhTW,
    settings: settings_zhTW,
    shortcuts: shortcuts_zhTW,
    common: common_zhTW,
    external: external_zhTW,
  },
}
export default resources

按需加载语言

当我们引入了越来越多的语言,我们就会发现,打包之后的体积也会越来越大。而用户一般只会使用一种语言,因此我们希望可以按需加载语言。

但是其实 i18next 并没有内置按需加载的逻辑,因此我们需要自己实现。首先我们需要修改 resource.ts 文件。

export const resources = {
  en: {
    app: en,
    lang: lang_en,
    common: common_en,
    external: external_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
  },
  'zh-CN': {
    lang: lang_zhCN,
    common: common_zhCN,
    settings: settings_zhCN, // [!code --]
    shortcuts: shortcuts_zhCN, // [!code --]
    common: common_zhCN, // [!code --]
    external: external_zhCN, // [!code --]
  },
  // 其他语言
}

这里我们除了英语是全量引入之外,其他语言都是按需引入。其次删除其他语言的大部分 namespace 资源,只保留 commonlang 两个 namespace。由于这两个 namespace 是通用模块的,并且大小也比较小,这里可以全量引入。在实际使用场景中,你也可以完全删除。比如:

export const resources = {
  en: {
    app: en,
    lang: lang_en,
    common: common_en,
    external: external_en,
    settings: settings_en,
    shortcuts: shortcuts_en,
  },
}

类似上面,只有一个英语的资源。现在我们可以改改文件名,resources.ts 改成 default-resources.ts。其他的不变。

接下来我们来实现如何按需加载语言。

大概的思路是:

  1. 通过 import() 去加载需要的语言资源的,然后使用 i18n.addResourceBundle() 去完成加载
  2. 然后再次调用 i18n.changeLanguage() 去切换语言
  3. 重新设置一个 i18next 实例,让组件重新渲染

创建一个 I18nProvider 去实现这个逻辑。

import i18next from 'i18next'
import { atom } from 'jotai'

export const i18nAtom = atom(i18next)

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom)
  return (
    <I18nextProvider i18n={currentI18NInstance}>{children}</I18nextProvider>
  )
}

然后监听 i18n 语言变化。这里注意即便是目前没有相关的语言,languageChanged 也会触发。

const loadingLangLock = new Set<string>()

const langChangedHandler = async (lang: string) => {
  const { t } = jotaiStore.get(i18nAtom)
  if (loadingLangLock.has(lang)) return

  const loaded = i18next.getResourceBundle(lang, defaultNS)

  if (loaded) {
    return
  }

  loadingLangLock.add(lang)

  const nsGlobbyMap = import.meta.glob('@locales/*/*.json')

  const namespaces = Object.keys(defaultResources.en) // 可以通过全量加载的英语中获取到所有的 namespace

  const res = await Promise.allSettled(
    // 通过 namespace 去加载对应的语言资源
    namespaces.map(async (ns) => {
      const loader = nsGlobbyMap[`../../locales/${ns}/${lang}.json`] // 这个路径每个项目可能都不一样,需要根据实际情况调整

      if (!loader) return
      const nsResources = await loader().then((m: any) => m.default)

      i18next.addResourceBundle(lang, ns, nsResources, true, true)
    }),
  )

  await i18next.reloadResources()
  await i18next.changeLanguage(lang) // 再次切换语言
  loadingLangLock.delete(lang)
}

useLayoutEffect(() => {
  const i18next = currentI18NInstance

  i18next.on('languageChanged', langChangedHandler)

  return () => {
    i18next.off('languageChanged')
  }
}, [currentI18NInstance])

这里注意,当语言加载完成之后,我们还需要重新调用 i18next.changeLanguage() 去切换语言。

在生产环境中合并 namespace 资源

在上面的例子中,我们拆分了多个 namespace 资源,但是在生产环境中,我们希望可以把所有的 namespace 资源合并成一个文件,这样可以减少网络请求的次数。

我们来写一个 Vite 插件,在生产环境中,把所有的 namespace 资源合并成一个文件。

function localesPlugin(): Plugin {
  return {
    name: 'locales-merge',
    enforce: 'post',
    generateBundle(options, bundle) {
      const localesDir = path.resolve(__dirname, '../locales') // 注意修改你的 locales 目录
      const namespaces = fs.readdirSync(localesDir)
      const languageResources = {}

      namespaces.forEach((namespace) => {
        const namespacePath = path.join(localesDir, namespace)
        const files = fs
          .readdirSync(namespacePath)
          .filter((file) => file.endsWith('.json'))

        files.forEach((file) => {
          const lang = path.basename(file, '.json')
          const filePath = path.join(namespacePath, file)
          const content = JSON.parse(fs.readFileSync(filePath, 'utf-8'))

          if (!languageResources[lang]) {
            languageResources[lang] = {}
          }
          languageResources[lang][namespace] = content
        })
      })

      Object.entries(languageResources).forEach(([lang, resources]) => {
        const fileName = `locales/${lang}.js`
        const content = `export default ${JSON.stringify(resources)};`

        this.emitFile({
          type: 'asset',
          fileName,
          source: content,
        })
      })

      Object.keys(bundle).forEach((key) => {
        if (key.startsWith('locales/') && key.endsWith('.json')) {
          delete bundle[key]
        }
      })
    },
  }
}

然后在 vite.config.ts 中引入。

import localesPlugin from './locales-plugin'

export default defineConfig({
  plugins: [localesPlugin()],
})

现在,打包之后的产物中,会生成一个 locales 目录,下面包含了所有的语言资源的合并后的文件。

当然除了这个插件还不行,我们继续修改 i18n-provider.tsx 中的 langChangedHandler 方法。

const langChangedHandler = async (lang: string) => {
  const { t } = jotaiStore.get(i18nAtom)
  if (loadingLangLock.has(lang)) return
  const isSupport = currentSupportedLanguages.includes(lang)
  if (!isSupport) {
    return
  }
  const loaded = i18next.getResourceBundle(lang, defaultNS)

  if (loaded) {
    return
  }

  loadingLangLock.add(lang)

  if (import.meta.env.DEV) {   // [!code ++]
    const nsGlobbyMap = import.meta.glob('@locales/*/*.json')

    const namespaces = Object.keys(defaultResources.en)

    const res = await Promise.allSettled(
      namespaces.map(async (ns) => {
        const loader = nsGlobbyMap[`../../locales/${ns}/${lang}.json`]

        if (!loader) return
        const nsResources = await loader().then((m: any) => m.default)

        i18next.addResourceBundle(lang, ns, nsResources, true, true)
      }),
    )

    for (const r of res) {
      if (r.status === 'rejected') {
        toast.error(`${t('common:tips.load-lng-error')}: ${lang}`)
        loadingLangLock.delete(lang)

        return
      }
    }
  } else {
    const res = await import(`/locales/${lang}.js`) // 使用 import 的方式加载
      .then((res) => res?.default || res)
      .catch(() => {
        toast.error(`${t('common:tips.load-lng-error')}: ${lang}`)
        loadingLangLock.delete(lang)
        return {}
      })

    if (isEmptyObject(res)) {
      return
    }
    for (const namespace in res) {
      i18next.addResourceBundle(lang, namespace, res[namespace], true, true)
    }
  }

  await i18next.reloadResources()
  await i18next.changeLanguage(lang)
  loadingLangLock.delete(lang)
}

区分开发环境和生产环境,在生产环境中使用 import 的方式加载语言资源,在开发环境中使用 import.meta.glob 的方式加载语言资源。

现在在生产环境中,测试切换语言,可以看到,只会请求一个文件。

动态加载日期库的 i18n

同样的,我们也要兼顾日期库的 i18n。这里以 dayjs 为例。

我们需要维护一个 Dayjs 的国际化配置的 import 表。类似:

export const dayjsLocaleImportMap = {
  en: ['en', () => import('dayjs/locale/en')],
  ['zh-CN']: ['zh-cn', () => import('dayjs/locale/zh-cn')],
  ['ja']: ['ja', () => import('dayjs/locale/ja')],
  ['fr']: ['fr', () => import('dayjs/locale/fr')],
  ['pt']: ['pt', () => import('dayjs/locale/pt')],
  ['zh-TW']: ['zh-tw', () => import('dayjs/locale/zh-tw')],
}

语言代码通过:https://github.com/iamkun/dayjs/tree/dev/src/locale 获取

然后我们就可以在 langChangedHandler 中使用 dayjsLocaleImportMap 去加载对应的语言资源。

const langChangedHandler = async (lang: string) => {
  const dayjsImport = dayjsLocaleImportMap[lang]

  if (dayjsImport) {
    const [locale, loader] = dayjsImport
    loader().then(() => {
      dayjs.locale(locale)
    })
  }
}

DX 优化:HMR 支持

如果我们不做任何处理,在开发环境中,当我们修改任何语言资源文件的 json,都会导致页面完全重载。而不是实时看到修改后的文字。

我们可以写一个 Vite 插件去实现 HMR。

function customI18nHmrPlugin(): Plugin {
  return {
    name: "custom-i18n-hmr",
    handleHotUpdate({ file, server }) {
      if (file.endsWith(".json") && file.includes("locales")) {
        server.ws.send({
          type: "custom",
          event: "i18n-update",
          data: {
            file,
            content: readFileSync(file, "utf-8"),
          },
        })

        // return empty array to prevent the default HMR
        return []
      }
    },
  }
}

/// 在 vite.config.ts 中引入

export default defineConfig({
  plugins: [customI18nHmrPlugin()],
})

现在当我们修改任何语言资源文件的 json,都不会导致页面完全重载,Vite 的 HMR 处理逻辑已经被我们捕获了。那么现在我们需要去手动处理他。在上面的插件中,当 json 修改,我们会发送一个 i18n-update 事件,我们可以在 i18n.ts 中处理该事件。

if (import.meta.hot) {
  import.meta.hot.on(
    "i18n-update",
    async ({ file, content }: { file: string; content: string }) => {
      const resources = JSON.parse(content)
      const i18next = jotaiStore.get(i18nAtom)

      const nsName = file.match(/locales\/(.+?)\//)?.[1]

      if (!nsName) return
      const lang = file.split("/").pop()?.replace(".json", "")
      if (!lang) return
      i18next.addResourceBundle(lang, nsName, resources, true, true)

      console.info("reload", lang, nsName)
      await i18next.reloadResources(lang, nsName)

      import.meta.env.DEV && EventBus.dispatch("I18N_UPDATE", "") // 加载完成,通知组件重新渲染
    },
  )
}

declare module "@/lib/event-bus" {
  interface CustomEvent {
    I18N_UPDATE: string
  }
}

I18nProvider 中监听该事件。

export const I18nProvider: FC<PropsWithChildren> = ({ children }) => {
  const [currentI18NInstance, update] = useAtom(i18nAtom)

  if (import.meta.env.DEV)
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(
      () =>
        EventBus.subscribe('I18N_UPDATE', () => {
          const lang = getGeneralSettings().language
          // 重新创建 i18n 实例
          const nextI18n = i18next.cloneInstance({
            lng: lang,
          })
          update(nextI18n)
        }),
      [update],
    )
}

计算语言翻译完成度

由于我们使用了动态加载的语言资源,那么计算语言翻译完成度不能在运行时进行了,我们需要在编译时就计算出来。

我们来写一个计算方法。

import fs from "node:fs"
import path from "node:path"

type LanguageCompletion = Record<string, number>

function getLanguageFiles(dir: string): string[] {
  return fs.readdirSync(dir).filter((file) => file.endsWith(".json"))
}

function getNamespaces(localesDir: string): string[] {
  return fs
    .readdirSync(localesDir)
    .filter((file) => fs.statSync(path.join(localesDir, file)).isDirectory())
}

function countKeys(obj: any): number {
  let count = 0
  for (const key in obj) {
    if (typeof obj[key] === "object") {
      count += countKeys(obj[key])
    } else {
      count++
    }
  }
  return count
}

function calculateCompleteness(localesDir: string): LanguageCompletion {
  const namespaces = getNamespaces(localesDir)
  const languages = new Set<string>()
  const keyCount: Record<string, number> = {}

  namespaces.forEach((namespace) => {
    const namespaceDir = path.join(localesDir, namespace)
    const files = getLanguageFiles(namespaceDir)

    files.forEach((file) => {
      const lang = path.basename(file, ".json")
      languages.add(lang)

      const content = JSON.parse(fs.readFileSync(path.join(namespaceDir, file), "utf-8"))
      keyCount[lang] = (keyCount[lang] || 0) + countKeys(content)
    })
  })

  const enCount = keyCount["en"] || 0
  const completeness: LanguageCompletion = {}

  languages.forEach((lang) => {
    if (lang !== "en") {
      const percent = Math.round((keyCount[lang] / enCount) * 100)
      completeness[lang] = percent
    }
  })

  return completeness
}

const i18n = calculateCompleteness(path.resolve(__dirname, "../locales"))
export default i18n

然后在 Vite 中引入这个编译宏。

export default defineConfig({
  define: {
    I18N_COMPLETENESS_MAP: JSON.stringify({ ...i18nCompleteness, en: 100 }),
  }
})

在业务中使用:

export const LanguageSelector = () => {
  const { t, i18n } = useTranslation("settings")
  const { t: langT } = useTranslation("lang")
  const language = useGeneralSettingSelector((state) => state.language)

  const finalRenderLanguage = currentSupportedLanguages.includes(language)
    ? language
    : fallbackLanguage
  return (
    <div className="mb-3 mt-4 flex items-center justify-between">
      <span className="shrink-0 text-sm font-medium">{t("general.language")}</span>
      <Select
        defaultValue={finalRenderLanguage}
        value={finalRenderLanguage}
        onValueChange={(value) => {
          setGeneralSetting("language", value as string)
          i18n.changeLanguage(value as string)
        }}
      >
        <SelectTrigger size="sm" className="w-48">
          <SelectValue />
        </SelectTrigger>
        <SelectContent position="item-aligned">
          {currentSupportedLanguages.map((lang) => {
            const percent = I18N_COMPLETENESS_MAP[lang]

            return (
              <SelectItem key={lang} value={lang}>
                {langT(`langs.${lang}` as any)}{" "}
                {/* 如果百分比是 100,则不显示 */}
                {typeof percent === "number" ? (percent === 100 ? null : `(${percent}%)`) : null}
              </SelectItem>
            )
          })}
        </SelectContent>
      </Select>
    </div>
  )
}

扁平 Key 的处理

为了开发方便,我们一般让 i18n 的数据更加扁平。键值全部扁平处理,为了后续能够直接通过搜索找到对应的文案。

例如这样:

{
  "copied_link": "Copied link to clipboard",
  "feed.follower_one": "follower",
  "feed.follower_other": "followers"
}

那么在实际业务中,按照模块去划分,这种方式会造成大量的重复前缀。

{
  "entry_actions.copy_link": "Copy link",
  "entry_actions.failed_to_save_to_eagle": "Failed to save to Eagle.",
  "entry_actions.failed_to_save_to_instapaper": "Failed to save to Instapaper.",
  "entry_actions.failed_to_save_to_readwise": "Failed to save to Readwise.",
  "entry_actions.link_copied": "Link copied to clipboard.",
  "entry_actions.mark_as_read": "Mark as read",
  "entry_actions.mark_as_unread": "Mark as unread",
  "entry_actions.open_in_browser": "Open in browser",
  "entry_actions.save_media_to_eagle": "Save media to Eagle",
  "entry_actions.save_to_instapaper": "Save to Instapaper",
  "entry_actions.save_to_readwise": "Save to Readwise",
  "entry_actions.saved_to_eagle": "Saved to Eagle.",
  "entry_actions.saved_to_instapaper": "Saved to Instapaper.",
  "entry_actions.saved_to_readwise": "Saved to Readwise.",
  "entry_actions.share": "Share",
  "entry_actions.star": "Star",
  "entry_actions.starred": "Starred.",
  "entry_actions.tip": "Tip",
  "entry_actions.unstar": "Unstar",
  "entry_actions.unstarred": "Unstarred.",
}

在「在生产环境中合并 namespace 资源」章节中提到,在生产中我们合并了 namespace,我们继续优化一下这个部分,让在生产中加载嵌套结构的 json 文件。

function localesPlugin(): Plugin {
  return {
    name: "locales-merge",
    enforce: "post",
    generateBundle(_options, bundle) {
      const localesDir = path.resolve(__dirname, "../locales")
      const namespaces = fs.readdirSync(localesDir)
      const languageResources = {}

      namespaces.forEach((namespace) => {
        const namespacePath = path.join(localesDir, namespace)
        const files = fs.readdirSync(namespacePath).filter((file) => file.endsWith(".json"))

        files.forEach((file) => {
          const lang = path.basename(file, ".json")
          const filePath = path.join(namespacePath, file)
          const content = JSON.parse(fs.readFileSync(filePath, "utf-8"))

          if (!languageResources[lang]) {
            languageResources[lang] = {}
          }

          const obj = {} // [!code ++]

          const keys = Object.keys(content as object) // [!code ++]
          for (const accessorKey of keys) { // [!code ++]
            set(obj, accessorKey, (content as any)[accessorKey]) // [!code ++]
          } // [!code ++]

          languageResources[lang][namespace] = obj // [!code ++]
        })
      })

      Object.entries(languageResources).forEach(([lang, resources]) => {
        const fileName = `locales/${lang}.js`

        const content = `export default ${JSON.stringify(resources)};`

        this.emitFile({
          type: "asset",
          fileName,
          source: content,
        })
      })

      // Remove original JSON chunks
      Object.keys(bundle).forEach((key) => {
        if (key.startsWith("locales/") && key.endsWith(".json")) {
          delete bundle[key]
        }
      })
    },
  }
}

这里我们通过 lodash.set 方法让扁平数据结构转换为嵌套结构。

总结

上面我们实现了一个比较完整的 i18n 解决方案。

包括了:

  • 全量引入
  • 按需引入
  • 动态加载
  • 生产环境合并 namespace
  • 计算语言翻译完成度
  • HMR 支持

此方案应用于 Follow 中。

https://github.com/RSSNext/follow

具体实现可以参考代码:

https://github.com/RSSNext/Follow/blob/dev/src/renderer/src/providers/i18n-provider.tsx

https://github.com/RSSNext/Follow/blob/dev/src/renderer/src/i18n.ts

(对了,此文章中隐藏了一枚 Follow 邀请码,你能找到吗?)

看完了?说点什么呢

写在中秋前

2024年9月14日 22:30
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/180

关于工作

首先来说说 Follow 吧。挺开心的也挺意外的,参与的一个前端项目在这周几乎每天都上了 GitHub Treading。这周榜首也是 Follow。因为这个项目我作为 Top 1 Contributor 也是挺有成就感的一件事。

https://x.com/follow_app_/status/1833378435340570993

https://x.com/follow_app_/status/1834410909449814086

虽然目前 Follow 仍在内测阶段,但是随着推荐机制的出现,实际用户人数也是越来越多了,甚至出现了花钱求码的阶段。真的非常感谢大家的支持。

我一直挺喜欢做开源的,也喜欢做更多能够接近用户的产品。我以前也有过梦想能通过全职写开源能够养活自己,看起来现在迈出了第一步。

随便人数的扩张,也少不了更多的工作压力。想了想我已经有好久没有休息过了,每个周末都想在怎么样让它变得更好。我总是打磨各个地方的细节和界面设计,也许真是因为这样收到了不少人的好评,还有得到了一些 UI 设计师的肯定。虽然我并不是专业的 UI Design Engineer。但我正有这个意向,往这个方向前进。事实证明,我确实没有迷路。

我承认我有点工作狂了。但是我却对此乐而不疲。也许这就是对开源和产品的热爱。我想让更多人看到和体验到一款好的产品。

wakatime 登顶国区第一

关于生计

我家楼下的店,半年之间已经开了关了关了开了换了几代人。

生意也是越来越难做了,却还是有人想尝尝做老板的滋味。最后,免不了的都是关店。

我已经不知道哪家店坚持了最长的时间了,也许是去年一对刚毕业的大学生开的蛋糕店吧,那一对小情侣,每天窝在店里一直在刷手机打游戏,虽然是蛋糕店,进店之后却没有任何售卖的蛋糕,日复一日。终于有天,旺铺出租了。

后来,来了一堆老夫妻,开了一家早茶店,依然每天生意清淡,看不到几个人。不久,也关门了。

而现在,我已经不知道是第几代目了。又开始装修了。而这次又能坚持多久。

而我身边的人,都在轮流失业。

关于出行

上周周末,去上海参加了 Rust Conf。但是我其实一点 Rust 都不会。但是白嫖了同事的门票,不去白不去。刚好杭州的朋友们,这周都计划去上海了。于是前往上海的人数差不多有十多人。

前一天晚上,就去上海了。不得不说上海的酒店确实贵,要是一个人住的话,真的承受不住,大概要 400 一晚。

!Rust Conf 现场

::: masonry :::

(对了,这篇文章的下面不要在评论求 Follow 的邀请码了,这会让我很困扰,谢谢你们)

看完了?说点什么呢

南京行、心境

2024年9月4日 22:02
该渲染由 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年8月30日 22:18
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/posts/tech/ShadowDOM-style-isolation-and-inheritance

如果你了解 Web Component 那么你一定知道 Shadow DOM,Shadow DOM 是用于创建一个与外部隔离的 DOM Tree,在微前端中比较常见,可以在内部定义任何样式也不会污染外部的样式。假如你使用 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年8月25日 19:46
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/178

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

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

https://innei.in/notes/177

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

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

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

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

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

看完了?说点什么呢

迷失在黄风岭

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

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

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

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

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

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

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

看完了?说点什么呢

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

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

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

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

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

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

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

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

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

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

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

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

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

看完了?说点什么呢

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

2024年7月24日 00:00
该渲染由 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

看完了?说点什么呢

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

2024年6月27日 15:00
该渲染由 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年6月22日 12:37
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/174

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

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

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

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

https://github.com/RSSNext/follow

看完了?说点什么呢

不敢改变是我在焦虑什么

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

事业

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

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

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

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

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

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

看完了?说点什么呢

瓶颈与迷茫

2024年5月28日 20:35
该渲染由 Shiro API 生成,可能存在排版问题,最佳体验请前往:https://innei.in/notes/172

工作两年了,能力好像没什么长进。

还在在校期间,有非常多的时间去探索方向,学习实用的感兴趣的东西,而我在那时学了 Vue,靠着这个得到了第一份实习的工作,然后在职期间学习了 React 和 Next.js。而我到现在技术水平一直停滞在那会,即便是过去了这么久,我依然只会这些技术栈。我使用过很多框架和 SaaS 服务,但这些并不是什么优势。我想到在我校招面试的时候,面试官总会说你虽然广度比较可以,但是没有深度。过去了两年,这个评价依然适用于我,即便是使用了 Next.js 作为主要的开发框架四年,虽然经历了历代的版本更新,但是在底层原理上依旧是全然不解,单单停留在应用层。

看着现在的校招生甚至是高中生会的都比已经在职场混迹几年的技术上更加精练,不由得感到自愧不如。在我应届那会,或者实习那会也是有着同样的想法,而现在只是角色互换了。技术发展的日新月异,在工作之后一直曾经在公司业务而没有太多关注注定会被时代发展脱节,慢慢的淘汰。

在这个互联网寒冬里,完全不知道自己的存在是否还会有竞争力,和那些应届生相比,也不再有性价比了。

看完了?说点什么呢

❌
❌