<![CDATA[王小明博客]]>https://16px.cc/RSS for NodeSat, 27 Apr 2024 06:22:40 GMT60<![CDATA[Next.js 你这样做显得我很憨好吗]]>我记得 Next.jsserver action 刚出来的时候,在客户端调用 server action,一旦抛错,是 catch 不住的,页面直接 500 了。然后我搞了一揽子工具函数,用于避免 server action 抛错。结果刚刚再试的时候,竟然可以 catch 住了,心态崩了呀。。。

]]>
https://16px.cc/blog/jc7kPKuJehzkhttps://16px.cc/blog/jc7kPKuJehzkWed, 24 Apr 2024 04:42:20 GMT
<![CDATA[记一次傻逼到爆炸的构建错误]]>数据库地址设置成了 localhost,本地构建一点问题没有,但是用 docker 构建的时候,不停地报错:

Can't reach database server at `localhost`:`3306`

我以为数据库连接数超了,或者构建时并发太大超时了,又是设置 connect_timeout,又是设置 pool_timeout;还以为是在 mac aarch64 上构建 linux/amd64 的问题,也换了,翻来覆去就是报错,心态炸裂了。

结果在某个灵光乍现的时候,突然发现,诶,怎么是 localhost……

docker 里面,鬼能连上你 host 里的 localhost 啊……

]]>
https://16px.cc/blog/prCD1rrttsjWhttps://16px.cc/blog/prCD1rrttsjWWed, 17 Apr 2024 18:41:41 GMT
<![CDATA[《艺术的故事》摘录与小评]]>好几年前就(给家人)买了《艺术的故事》,一直放在家里,没机会、也沉不下心来读,最近终于能沉下心来看一点了,在这里记录一下吧。


不过,我们圈外人通常为之焦虑的美和表现的观念,艺术家却很少谈起……部分原因在于:艺术家往往腼腆怕羞,说“美”这类大话觉得不好意思。谈到“表现他们的感情”,以及类似的讲法,更会觉得装模作样。他们把这些看作理所当然,讨论起来也没有益处。

羞于谈论一些在外界看起来高大上的概念,因为觉得是“理所当然”的。好像有点共鸣哦。

原始……部落人……不要以为他们的作品看起来不顺眼就认为他们的手艺不过如此。他们的作品与我们的作品不同不是由于技艺,而是由于观念。从一开始就认识这一点十分重要,因为整个艺术发展史不是技术熟练程度的发展史,而是观念和要求的变化史。

工作生活中是否也是这样呢?他人在某些技能方面不足,或许只是观念和所处环境的缘故。(但是慎用这个来解释自己

]]>
https://16px.cc/blog/LGxTWHTD5eDlhttps://16px.cc/blog/LGxTWHTD5eDlWed, 27 Mar 2024 15:56:38 GMT
<![CDATA[我的第一份前端工作竟然不是前端]]>刚刚在清理邮箱,打开我收到的第一个前端 offer,才发现,它的 title 竟然不是前端,而是个什么鬼“VR设计师”

image.png

]]>
https://16px.cc/blog/rWJFIwyxlJ_Xhttps://16px.cc/blog/rWJFIwyxlJ_XThu, 21 Mar 2024 15:07:42 GMT
<![CDATA[选择题]]>有时候,有些事,做了也不一定有好的结果。

但我不希望在以后的时光里,回忆起这一段时间,抱怨自己,“我怎么就没有xxx”。

离职告知截图


P.S. 裸辞失败,老大说让我先别辞,给我放两个月假去处理事情。。。

]]>
https://16px.cc/blog/xR6DClIS7mqDhttps://16px.cc/blog/xR6DClIS7mqDThu, 21 Mar 2024 14:18:19 GMT
<![CDATA[业务中的跨域策略和 SameSite Cookie]]>

注意,我们整篇文章都在讨论跨域,相同域下的资源访问是没有这些问题的。

前端很多时候,会需要请求跨域的资源(接口或图片等)。

你是资源提供方

你希望限制访问

这时候,你就需要用到跨域资源共享(CORS)了,你可以通过在 Response header 上添加 Access-Control-* 系列字段,明确声明你的资源的访问策略。如:

  • Access-Control-Allow-Origin 限制仅某些域才能访问
  • Access-Control-Allow-Methods 只接受特定方法(GetPost 等)的访问
  • Access-Control-Allow-Credentials 请求要不要携带CredentialsCredentials 可以是 cookiesauthorization headersTLS client certificates
  • 等等

此外,我们知道 Cookie 在网络传输中扮演着很重要的角色,你希望在各种请求中控制 Cookie 是否发送。

此时,虽然 Access-Control-Allow-Credentials 可以控制跨域请求是否允许携带凭据,但它只能在 CORS 请求方面起作用。

如果一个恶意网站用 iframe 嵌套了你的网站,恶意网站有可能会误导用户执行特定的操作。此时用户的操作携带 Cookie 的话,将会很危险。

别担心,有 SameSite Cookie

SameSite Cookie 属性是一种更广泛的机制,可以控制在所有类型的跨站点请求(包括页面导航、图片加载、iframe 加载等)中是否发送 Cookie。

建议看看阮一峰的 Cookie 的 SameSite 属性,介绍得很清楚。

Chrome 默认的 SameSiteLax,是比较严格的,只有一些通常意义上不会造成资源修改的操作才允许携带 Cookie,最大限度地保障了用户的网络安全。

你希望允许访问

我们在业务中经常碰到的问题是,两个域都是我自己的,为什么这个跨站请求没有携带 Cookie 呢?

此时,我们服务端需要在 Response header set-Cookie 字段中,声明 SameSite=None

set-Cookie: xxx=xxx;Domain=.your-target.site;SameSite=None;Secure

这样,*.your-target.site 就能附带 Cookie 访问你的资源了。

你是访问者

通常,服务提供者是不会随便对所有域设置 SameSite=None 的,通常只会对特定的几个域(他自己持有的或信任的域)设置 SameSite=None

假设 third-party.site 是一个很牛哔的站点,它提供了网络基础设施,你今天访问了 A/B/C/D/E 各种网站,都引用了 third-party.site 的资源。

如果浏览器允许随便携带 Cookie,那就糟了,third-party.site 就知道你的访问记录了。这就叫跨站跟踪。

所以,Chrome 默认 SameSite=Lax,很多请求都不允许携带 Cookie,尽可能保护用户的隐私。

当然,你说如果 third-party.site 设置所有域都 SameSite=None,那不就能跟踪了吗?确实,这样是能跟踪了,但是它自身也冒着其他的一些安全风险,就不赘述了。

业务上的常见问题

  1. 我本地开发,SameSite 有问题啊

    本地开发的时候,我们可能需要在其他设备上访问本机起的服务,通常是访问 http://192.168.x.x,这时候就有问题了:

  • SameSite=None 必须要 Secure 才能生效,可是 http://192.168.x.x 不是一个安全的域

  • SameSite=Lax 又会导致一些跨域请求被阻止

    这时候可以再起一个 https 服务,代理到 http 的端口上,其他设备上就能访问 httpsSameSite=None 就能生效了。

推荐一个方便地起 https 的命令行工具:local-ssl-proxy

  1. 我不是允许跨域了吗?怎么请求还是被阻止了?

    注意看看 Request header 是不是添加了一些自定义的字段,如果有,需要在 Response header Access-Control-Expose-Headers 中添加该字段。

注意看 devtool -> Networkdevtool -> Console tab 中的报错信息,一般报错信息会详细地告诉你请求为什么被阻止。


P.S. 前段时间写的 《为什么 Chrome 要禁止跨域》 被我不小心误删了,才发现数据库备份脚本权限有问题,一直没有执行。删除也是物理删除,没有做假删除,直接找不回来了。尬了,只能重新写了。。。

]]>
https://16px.cc/blog/OlxyhzZbRyUIhttps://16px.cc/blog/OlxyhzZbRyUISat, 16 Mar 2024 08:11:35 GMT
<![CDATA[为什么 nextjs 项目, 访问不到 public 目录下的 html 文件?]]>发现问题

打算搞一个百度收录, 百度需要我在根目录放一个 baidu_verify_xxx.html 的文件, 用于验证。

我一想, 这不太简单了, 直接把 .html 文件扔到 public 目录下不就好了?

扔进去, 测试一下, 404… 当时就猜, 应该被 nextjs 拦截了, 一看源码, 果然…

瞄瞄源码

以下仅截取关键代码

class NextNodeServer extends BaseServer {
  /**
   * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/next-server.ts#L642
   */
  protected async findPageComponents() {
    /**
     * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/next-server.ts#L710
     */
    const components = await loadComponents({
      distDir: this.distDir,
      page: pagePath,
      isAppPath,
    })
  }
}

loadComponents 中:

function loadComponents() {
  /**
   * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/load-components.ts#L135
   */
  const ComponentMod = await Promise.resolve().then(() =>
    requirePage(page, distDir, isAppPath)
  )
}

requirePage:

/**
 * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/require.ts#L108
 */
function requirePage() {
  const pagePath = getPagePath(page, distDir, undefined, isAppPath)
  if (pagePath.endsWith('.html')) {
    return promises.readFile(pagePath, 'utf8').catch((err) => {
      throw new MissingStaticPage(page, err.message)
    })
  }
}

getPagePath:

/**
 * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/require.ts#L93
 */
export function getPagePath(
  page: string,
  distDir: string,
  locales: string[] | undefined,
  isAppPath: boolean
): string {
  const pagePath = getMaybePagePath(page, distDir, locales, isAppPath)

  if (!pagePath) {
    throw new PageNotFoundError(page)
  }

  return pagePath
}

getMaybePagePath (此处完整截取了该函数代码):

/**
 * https://github.com/vercel/next.js/blob/canary/packages/next/src/server/require.ts#L25
 * 
 * 太直白了, 没什么好注释解释的了, 就是:
 * 1. 从缓存里取, 如果没取到:
 * 2. 构建的时候会生成 .next/server/app-paths-manifest.json 和 .next/server/pages-manifest.json, 看看这两个文件里面有没有这个路径, 并拼上 .next/server
 */
export function getMaybePagePath(
  page: string,
  distDir: string,
  locales: string[] | undefined,
  isAppPath: boolean
): string | null {
  const cacheKey = `${page}:${distDir}:${locales}:${isAppPath}`

  let pagePath = pagePathCache?.get(cacheKey)

  // If we have a cached path, we can return it directly.
  if (pagePath) return pagePath

  const serverBuildPath = path.join(distDir, SERVER_DIRECTORY)
  let appPathsManifest: undefined | PagesManifest

  if (isAppPath) {
    appPathsManifest = loadManifest(
      path.join(serverBuildPath, APP_PATHS_MANIFEST),
      !isDev
    ) as PagesManifest
  }
  const pagesManifest = loadManifest(
    path.join(serverBuildPath, PAGES_MANIFEST),
    !isDev
  ) as PagesManifest

  try {
    page = denormalizePagePath(normalizePagePath(page))
  } catch (err) {
    console.error(err)
    throw new PageNotFoundError(page)
  }

  const checkManifest = (manifest: PagesManifest) => {
    let curPath = manifest[page]

    if (!manifest[curPath] && locales) {
      const manifestNoLocales: typeof pagesManifest = {}

      for (const key of Object.keys(manifest)) {
        manifestNoLocales[normalizeLocalePath(key, locales).pathname] =
          pagesManifest[key]
      }
      curPath = manifestNoLocales[page]
    }
    return curPath
  }

  if (appPathsManifest) {
    pagePath = checkManifest(appPathsManifest)
  }

  if (!pagePath) {
    pagePath = checkManifest(pagesManifest)
  }

  if (!pagePath) {
    pagePathCache?.set(cacheKey, null)
    return null
  }

  pagePath = path.join(serverBuildPath, pagePath)

  pagePathCache?.set(cacheKey, pagePath)
  return pagePath
}

稍加分析

nextjs 对于 .html 后缀的访问, 直接去生成的 apppages 目录里面找, 找不到就直接返回 404 了, 根本不管你 public 目录里面有没有

解决方案

虽然可以从源码中添加一个 baidu_verify_xxx 文件, 但我感觉这样挺烦的, 干脆, 我们原来的在 nginx 中的做法不是把所有的东西都扔给 nextjs 处理嘛, 现在不了, nginx 先去 public 目录里面找一找, 有就直接返回, 没有再交给 nextjs

server {
+  try_files    /public$uri @nextjs;

-  location / {
+  location @nextjs {
    proxy_pass    http://nextjs_upstream;
  }
  # ...
}

p.s. 其实, 像这种情况, nginx 直接返回文本省事多了🙃 但我要是不用 try_files, 前面的源码不是白分析了吗 [狗头] 这样一搞, 什么 html 静态文件都能用了 [正色]

~~再 p.s. 其实, 不适合一股脑 try_files, 因为如果是图片, 交给 nextjs 处理, next/image 会做优化, 直接由 nginx 丢出去, 优化就无效了~~

题外话

配置好后, 页面仍然报 404, 哪里出了问题呢?

  1. 看看 nginx 是谁执行的
  • 执行 ps aux | grep '[n]ginx'
  • 哦, 原来是 www-data 用户
  1. 看看 www-data 用户能不能访问到相应的文件
  • 执行 sudo -u www-data stat /path-to-target-file
  • 也可以执行 sudo -u www-data namei /path-to-target-file, 这个命令能更清楚地看到是在哪一目录才 denied
  • md permission denied

哦哦, 查一下, 发现 nginx 需要 x 权限, 而我的项目放在 /home/xxx 目录下, 权限是 750

教训就是, 项目别放到 /home 目录下!

warning

chatgpt 说:

对于普通的Web服务器,try_files 的影响通常是可以忽略不计的。但是,在高性能要求的环境中,需要谨慎使用,并进行性能测试以确保不会成为性能瓶颈。如果性能成为问题,可能需要考虑其他优化策略,如缓存、负载均衡等。

]]>
https://16px.cc/blog/bUTsjruylOsOhttps://16px.cc/blog/bUTsjruylOsOSun, 17 Mar 2024 16:27:07 GMT
<![CDATA[mysql 从安装到连接]]>说在前面

本文所有前缀以 CUSTOM_ 的字段, 都表示你需要改为你自己的值,
CUSTOM_YOUR_PASSWORD, 表示你需要替换为你自己的密码;

本文所有前缀以 mysql> 的命令, 都表示该条命令处于 mysql 环境, 输命令时是不需要输入 mysql>

安装及启动

sudo apt install mysql-server

sudo systemctl start mysql

[可选] 配置 root 用户密码

最开始 root 用户是没有密码的, 我们给它配置一个密码, 当然不配置密码也无所谓, 密码强弱也无所谓, 因为 root 的 host 是 localhost, 外网访问不了

sudo mysql

mysql> ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'CUSTOM_YOUR_PASSWORD';
mysql> exit;

[可选] 配置公网可访问

如果你不需要公网访问, 可以忽略该段

添加/编辑 bind-address, 以使公网可访问;

sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf

# 在文件 "/etc/mysql/mysql.conf.d/mysqld.cnf" 中, 添加/编辑 bind-address, 设置为 0.0.0.0
bind-address = 0.0.0.0

创建数据库

sudo mysql -u root -p

mysql> CREATE DATABASE CUSTOM_YOUR_DATABASE;

创建用户, [可选] 配置外网访问权限

@'%' 表示 host%, 即任意地址可访问 (如需配置特定地址/禁止外网访问, 可另查资料)

仅内网可访问是 @'localhost'

只有 host% 的用户, 才能外网访问;

# 这个密码必须很强, 因为这个用户是外网访问的 (如果你仅内网可访问, 那就无所谓了)
mysql> CREATE USER 'CUSTOM_USER'@'%' IDENTIFIED BY 'CUSTOM_YOUR_PASSWORD';
mysql> GRANT ALL PRIVILEGES ON CUSTOM_YOUR_DATABASE.* TO 'CUSTOM_USER'@'%';
mysql> FLUSH PRIVILEGES;
mysql> exit;

# 重启 mysql
sudo systemctl restart mysql

常见问题与排查

如果外网访问不了, 你可以检查:

  • 域名/用户名/密码/数据库名 是否正确
  • 用户 host 是否为 %
  • 检查用户 host 的命令是 mysql> select user, host from mysql.user;
  • 腾讯云/阿里云 防火墙端口是否开放
  • 用户等杂七杂八的东西配置完后, 是否忘了重启 mysql

外网客户端可以连上, prisma 死活连不上

执行 npx prisma db push 报错:

Error: P1001: Can't reach database server at `xxx.xxx.xx.xx`:`3306`

可以先执行 npx prisma db seed (嗯嗯, 也会报错), 然后再执行 npx prisma db push, 就 OK 了

设置密码抛错

有时候不小心将密码强度设置有误, 修改密码的时候抛错:

ERROR 1819 (HY000): Your password does not satisfy the current policy requirements

可以修改密码策略:

# 列出当前密码校验的策略
mysql> SHOW VARIABLES LIKE 'validate_password%';

+-------------------------------------------------+--------+
| Variable_name                                   | Value  |
+-------------------------------------------------+--------+
| validate_password.changed_characters_percentage | 0      |
| validate_password.check_user_name               | ON     |
| validate_password.dictionary_file               |        |
| validate_password.length                        | 8      |
| validate_password.mixed_case_count              | 1      |
| validate_password.number_count                  | 1      |
| validate_password.policy                        | MEDIUM |
| validate_password.special_char_count            | 1      |
+-------------------------------------------------+--------+

# 然后就可以调整密码策略了, 例如不要特殊字符:
mysql> SET GLOBAL validate_password.special_char_count = 0;
]]>
https://16px.cc/blog/w9Sg9JLGnyf2https://16px.cc/blog/w9Sg9JLGnyf2Sat, 27 Jan 2024 17:37:17 GMT
<![CDATA[ts 如何表示 string + string literal 的联合类型?]]>问题

当我们有一个类型Color,其能接受如#123456这样普通的string,你又希望编辑器能提示你诸如redblue这样特定的string literal时:

// 不合理
type Color = 'red' | 'blue' | string

上述写法不合理,Color类型会被编译器缩减为string,失去智能提示。

解决方案

下面的写法,编辑器能正确地提示我们 redblue,也能接受普通的字符串如#123456

// https://azukiazusa.dev/blog/shorts/4Tq3csJ5BwAXPR4OBuJvIo/
// 能达到目的
type Color = 'red' | 'blue' | (string & {})

存在的不足及优化

但上面的写法仍然存在不足[^1]:这样的Color能接受任何字符串,即使笔误写成bluu或者invalid-color,编辑器/编译器也不能给我们任何提示。

[^1]: Type-safe alternatives: https://stackoverflow.com/a/73753173

因此,在一些条件允许的情况下,我们可以写出更严格的类型:

// 当然,如果你认为不必要这么严格,或者还不够严格
// 可以按自己需求/喜好来调整,此处只作示例
type Color = 'red' | 'blue' | `#${string}` | `rgb(${string})`

效果展示

输入时编辑器智能提示

类型错误提示

]]>
https://16px.cc/blog/cUrQcu5f4fDchttps://16px.cc/blog/cUrQcu5f4fDcThu, 18 Jan 2024 16:55:30 GMT
<![CDATA[记一次由接口响应顺序导致的 bug]]>前言

业务中存在一个逻辑:

如果接口响应 401,就清空前端用户信息

貌似是一个很合理的逻辑,401 肯定是登录态有问题,当然需要清空登录态。

碰到问题

线上偶现部分页面业务逻辑异常,顺着业务梳理排查:

其他地方传过来 token,前端凭 token 调登录接口,拿到了登录态,存起来了,可突然转个身,登录态没了。。。

排查

检查代码逻辑没问题,检查现场请求发现,登录请求前面紧挨着有几个 401,请求时间比较长。

原来是:

  • 页面顺序发了几个请求: api: x + api: login
  • api: login 后发,但提前响应回来了,前端保存了登录态;
  • api: x 先发,发的时候没有登录态,但之后才响应 401,触发了 401 的逻辑,清空了刚刚保存的登录态。。。

修复

最合理的解决方案是:记录请求的发送时间,记录最后一次登录成功的时间,在触发 401 清空登录态时,判断请求发送时间是否早于登陆成功的时间,如果是,就忽略(不执行清空登录态)。但由于有历史包袱,改动过大,没法搞。

裱糊匠有办法:记录最后一次登录成功的时间 T,忽略 T + 1s 时间内的 401 清空登录态的调用。

什么,你说万一前面的请求超过 1s 了呢?2s 够不够?3s 够不够?

不够?那就只能牺牲我的头发,用上面那个难搞的方案咯。(你说 10s?你是真不让别人退出了呗。。。)

]]>
https://16px.cc/blog/v9ZXgueczXLNhttps://16px.cc/blog/v9ZXgueczXLNThu, 18 Jan 2024 17:44:52 GMT
<![CDATA[一种组织 zustand 自定义函数的方法]]>问题

我们在使用 zustand 的时候,总会遇到需要自定义函数:

// 定义
const useCounter = create((set) => ({
  count: 0,
  inc: () => {
    set((prev) => ({
      count: prev.count + 1,
    }))
  },
}))

// 使用
function App() {
  const { count, inc } = useCounter()

  return <button
    onClick={inc}
  >
    {count}
  </button>
}

这样有几个问题:

  1. 函数污染了 store,我们在使用 useCounter 的时候,inc 函数总是在提示列表里面碍眼;
  2. 当我们需要在 hook 里面使用函数时,平白增加了依赖
   function App() {
     const { count, inc } = useCounter()

     useEffect(() => {
       console.log(count)
       inc()
       // inc 是一个静态函数,按理来说是可以不用放在依赖里面的
     }, [count, inc])
   }
  1. 没有类型限制,无法阻止别人无意间修改 inc 函数:
   // 在一些场合下,我们希望使用 replaceFlag,这时候就出问题了,把 inc 函数丢了
   // replace flag: https://docs.pmnd.rs/zustand/guides/immutable-state-and-merging#replace-flag
   const replaceFlag = true
   useCounter.setState({
     count: 3,
   }, replaceFlag)

这个怎么样?

如果能这样呢:

function App() {
  const { count } = useCounter()

  return <button
    // 将 inc 变成 useCounter 的静态函数
    onClick={useCounter.inc}
  >
    {count}
  </button>
}

直接上代码

/* eslint-disable @typescript-eslint/no-explicit-any */
import type { StoreApi, UseBoundStore } from 'zustand'

interface StaticFuncs {
  [key: string]: (...args: any[]) => any
}

type WithStatic<
  T extends UseBoundStore<StoreApi<any>>,
  S extends StaticFuncs
> = T & S

export function withStatic<T, S extends StaticFuncs>(
  useStore: UseBoundStore<StoreApi<T>>,
  staticFuncs: S
) {
  const protectedKeys = Object.keys(useStore)
  const result = useStore as WithStatic<UseBoundStore<StoreApi<T>>, S>
  Object.keys(staticFuncs).forEach((key) => {
    if (protectedKeys.includes(key)) {
      if (isDev) {
        throw new Error(`protected key: ${key}`)
      } else {
        console.error(`protected key: ${key}`)
      }
      return
    }
    result[key as 'setState'] = staticFuncs[key]
  })
  return result
}

Usage

const useRawCounter = create(() => ({
  count: 0,
}))

export const useCount = withStatic(useRawCounter, {
  inc: () => {
    useRawCounter.setState((prev) => ({
      count: prev.count + 1,
    }))
  },
})

// 调用
function App() {
  const { count } = useCounter()

  return <button
    onClick={useCounter.inc}
  >
    {count}
  </button>
}
]]>
https://16px.cc/blog/ZAk6vW7UmNIFhttps://16px.cc/blog/ZAk6vW7UmNIFFri, 12 Jan 2024 01:30:12 GMT
<![CDATA[抛开 SEO,我们为什么需要 react server component]]>今天不谈 SEO,我们梳理前端的发展历程,来看看我们为什么需要 react server component。

以下代码基本是伪代码

刀耕火种:html + js 一把梭

<html>
  <body>
    <header>xxx</header>
    <main>xxx</main>
    <footer>
      <div id="time">loading...</div>
    </footer>
    <script src="index.js" />
  </body>
</html>
// index.js
const timeElem = document.querySelector("#time")
const now = new Date().toLocaleString()
timeElem.innerText = now

html + js + css三剑客的时候,我们通常会把 js 放到 html 最底部,防止阻碍页面的加载。

这时候,运行很快,但不便于前端组织代码。

鸟枪换炮:单页面一把梭

<html>
  <body>
    <div id="app" />
    <!-- 构建时会将 js 注入到这里 -->
  </body>
</html>

单页面的时候,所有的页面内容和逻辑都在 js 里,html 只包含一个 js 入口和一个根元素(div#app),js 加载完成后运行,渲染出所有内容,插入到根元素(div#app)中。

这时候,路由一般由前端来控制,组织代码是方便了,但是由于所有内容都在 js 中,用户加载完一个 html 完全白屏,什么也看不到,必须要等到 js(size 通常 1M+)加载完成并执行后,才能看到内容,用户体验很糟糕。

再快一点:服务端渲染

tsx 源码(示意)

function Time() {
  const now = new Date().toLocaleString()
  return <div id="time" onClick={xxx}>
    {now}
  </div>
}

function App() {
  return <>
    <header>xxx</header>
    <main>xxx</main>
    <footer>
      <Time />
    </footer>
  </>
}

构建后的代码(示意)

  • index.html

    <html>
      <body>
        <header>xxx</header>
        <main>xxx</main>
        <footer>
          <!-- js 执行后将会替换掉下面这个 div -->
          <div data-id="time" />
        </footer>
        <script src="index.js" />
      </body>
    </html>
    
  • index.js js const timeElem = document.querySelector("[data-id='time']") const now = new Date().toLocaleString() timeElem.innerText = now // 为元素绑定事件,即 hydrate 水合 timeElem.onclick = xxx

服务端渲染和单页面类似,但是考虑到 js 中有一些逻辑并不依赖客户端(浏览器),所以可以先在服务端执行,渲染出尽可能多的内容,直接写到 html 中,再发送给客户端。

客户端接收到的内容不再是空的 html 了,而是包含部分 DOM 结构,用户能立即看到东西,只是不能交互,等到客户端加载完 js,完成水合过程,就能执行相应交互了。

精益求精:server action

诚然,服务端渲染能让我们立即看到东西,但是需要等到 js 加载完了才能交互。有没有办法在 js 没加载的时候就能交互呢?server action 可以

function App() {
  const serverAction = async (formData: FormData) => {
    "use server"
    const name = formData.get("name")
    // do something
  }

  return <form action={serverAction}>
    <input name="name" />
    <button type="submit">提交</button>
  </form>
}

react 会将 serverAction 代码放在服务端执行,即使尚未加载 js,或用户禁用了 js,浏览器也原生支持表单提交,具体可查看 react 文档 server-actions-and-mutations

More

我们平时写代码的时候,可以:

  • 适当多地让代码在服务端执行("use server"
  • 适当多地拆分粒度,客户端组件尽量出现在 DOM 树的叶子部分
  • 这样可以降低需要向客户端传输的 js 的尺寸
  • 尽可能使用浏览器原生支持的功能
  • 使用 <a> 标签而非 onClick + route
  • 使用 form + action 而非 onClick
]]>
https://16px.cc/blog/AO0XPQwqZb0Uhttps://16px.cc/blog/AO0XPQwqZb0USat, 06 Jan 2024 11:31:10 GMT
<![CDATA[【转载】nginx 中 location 匹配优先级的问题]]>今天逛 V 站发现一个 nginx 中 location 优先级的问题,有位用户 Hopetree 总结得挺好,图也很易懂,特此记录

Nginx 匹配优先级示意图

作者个人博文展开讲了这个问题: 🚀终于理解了Nginx配置中location规则的优先级问题

p.s. 记录一个 nginx 配置在线匹配网站: https://nginx.viraptor.info/

]]>
https://16px.cc/blog/S1PucL-ErJFJhttps://16px.cc/blog/S1PucL-ErJFJFri, 29 Dec 2023 06:40:34 GMT
<![CDATA[闲来无事,写了几个时钟玩玩]]>表盘来自 https://free-dxf.com/public/vectors-doors/free-download?search=clock

  • 《如果我是___,你会爱我吗》
  • 《爱你》,但是骂你
]]>
https://16px.cc/blog/Xo1SmCzkTxsbhttps://16px.cc/blog/Xo1SmCzkTxsbSat, 02 Dec 2023 05:51:55 GMT
<![CDATA[《重启人生》]]>全剧最令我感动的是,下面这个片段,时隔七十多年后,麻美和真里向小夏和小美发出邀请,一起喝茶聊天

麻美的表情满溢着惊喜、不敢置信、兴奋,让人动容。


上个月看的,看完就把观后感起了个头,本来想着构思构思多写点的,结果转个身忘了,今天后台翻列表才发现这篇未发表,可惜现在已经写不出什么了,只是强烈推荐没看过的可以看看,再次感叹日本人真是擅长拍友情、拍人生,之前的《小偷家族》也是,不像国内,拍什么东西都要往里面塞烂大街的爱情。

]]>
https://16px.cc/blog/W4rl8iCkoFyLhttps://16px.cc/blog/W4rl8iCkoFyLFri, 15 Dec 2023 21:07:03 GMT
<![CDATA[介绍一个学习 CSS 的网站]]>介绍一个学习 CSS 的网站, https://www.cssportal.com/, 站内涵盖了众多 CSS 现成、可配置的实践案例, 整个网站有一些广告, 但是不太影响使用。

推荐示例

  1. 各种贝塞尔曲线动画效果演示

动画演示.gif

你还可以一键查看不同曲线的效果动画

动画列表.png

  1. clip path 实现代码及效果

clip-path.jpg

  1. 各种精美的 loading 图

spin.gif

下图可以看到只有一个 dom 元素, 很简洁, 也便于我们定制化

spin.jpg

目录

categories.jpg

]]>
https://16px.cc/blog/SWnQrBVd8y7yhttps://16px.cc/blog/SWnQrBVd8y7yWed, 08 Nov 2023 14:11:07 GMT
<![CDATA[我们应该抵制不合理的募捐]]>今天看到知乎上一条提问: 如何看待「成都恶犬撕咬女童」事件家属发起200万募捐?, 里面有一条评论是:

没问题啊,我看孩子可怜捐点钱怎么了?你觉得不行你不捐不就行了。至于孩子家里有钱没钱什么的关我捐钱的什么事?

那么, 看待募捐, 能简单地说 我看孩子可怜捐点钱怎么了?你觉得不行你不捐不就行了 吗? 我认为不行。

注意, 本文并不表示我赞同或者不赞同此次募捐, 本文并不是针对此次募捐的评论, 而是对 "募捐" 这一行为/概念的讨论。

募捐, 对于整个社会来说, 并不仅仅事关 "发起募捐者" 和 "愿意捐款者" 两者, 同时, 还会消耗整个社会的 "同情心" 和 "注意力"。

一方面, 募捐的泛滥, 会导致热心人士以及普通人(中立者)注意力的分散, 会导致真正迫切需要帮助的信息被淹没, 从而更难从社会得到妥善的帮助;

另一方面, 每一次不合理的募捐, 都是对社会热心人士泼的一盆凉水, 是对同情心的消费。

因此, 对于不必要(不合理)的募捐行为, 我们应该理直气壮地抵制。


顺便, chatgpt 牛啊, 我把这篇文章发给他帮我润色, 下图左边是我原文, 右边是经过润色后的, 可以发现确实通顺了不少。(但是我就是不改, 诶, 就是玩儿)

原文和经chatgpt润色后的文章的对比

]]>
https://16px.cc/blog/MpgeqGBS893zhttps://16px.cc/blog/MpgeqGBS893zThu, 19 Oct 2023 03:12:40 GMT
<![CDATA[想想 svg 会怎么做]]>对于一些活动页, 有时候设计那边会出一些奇思妙想的设计稿, 对于一些需要支持动态修改的、含有特殊效果的文本, 如果 css 不能实现, 我们也可以考虑拼接 svg. 如下图:

活动设计稿节选

产品需要动态配置活动起止日期, 而设计稿中有描边, css 中的 text-stroke 效果不好, 太生硬, 我们就可以在 react 中拼接 svg, 文本可以使用变量

const svg = <svg
  xmlns="http://www.w3.org/2000/svg"
  viewBox="0 0 180 24"
  width={`${180 * 6}px`}
  height={`${24 * 6}px`}
  fill="#BB7A09"
  style={{
    fontSize: "16px",
    lineHeight: "1.5",
    fontWeight: "500",
    textAnchor: "start",
  }}
>
  <text
    x="4"
    y={24 / 2}
    stroke="white"
    strokeWidth="4.5px"
    strokeLinecap="round"
    strokeLinejoin="round"
    // dominantBaseline 需要设置到 text 上, safari 不会从父节点继承该属性
    dominantBaseline="central"
  >
    活动截止日期:11月31日 {/* 这儿可以按需使用变量 */}
  </text>
  <text x="4" y={24 / 2} dominantBaseline="central">
    活动截止日期:11月31日 {/* 这儿可以按需使用变量 */}
  </text>
</svg>

需要什么效果, 就可以拉着 UI 一起配合写出相应效果, 主打的就是一个灵活。更多的 svg 的奇思妙想, 可以看看 tympanus.net/codrops, 里面有很多花里胡哨天马行空的交互、UI.

P.S. svg 当然也有劣势, 排版/换行什么的需要手动计算。但是我们只是在少量的地方自己写 svg, 大部分的 svg 直接从设计软件中导出来就能用, 所以无所谓。

]]>
https://16px.cc/blog/5-4WU7eliMEdhttps://16px.cc/blog/5-4WU7eliMEdWed, 18 Oct 2023 03:45:44 GMT
<![CDATA[奇奇怪怪的 Date]]>我们知道, Date 实例有一些 set 方法, 如设置月份 setMonth, 设置日期 setDate, 设置时分秒 setHours, 他们都可以接收零或者负数。

我们知道, setMonth(0) 表示将 Date 实例的月份设置为第 0 个月, 即 "1月"

const d = new Date('2023-06-06 06:06:06')
d.setMonth(0)
// 2023/1/6 06:06:06
console.log(d.toLocaleString())

我们也知道, setHours(0) 表示将 Date 实例的 hour 设置为第 0 小时, 即 "0点钟"

const d = new Date('2023-06-06 06:06:06')
d.setHours(0)
// 2023/6/6 00:06:06
console.log(d.toLocaleString())

但, setDate(0) 并不会将日期设置为本月第 0 天(1 号), 而是上月的最后一天…

const d = new Date('2023-06-06 06:06:06')
d.setDate(0)
// 2023/5/31 06:06:06
console.log(d.toLocaleString())

或许是因为 getDate() 返回值是 1-31?

同理, 参数为 -1 就显然了

]]>
https://16px.cc/blog/fYj4OYafYbOkhttps://16px.cc/blog/fYj4OYafYbOkSun, 29 Oct 2023 06:40:54 GMT
<![CDATA[记一次接入支付宝时 XSS 风险]]>
  • 不是支付宝的问题, 是我们自己业务的问题;
  • 由于 xss 风险出在桌面端支付宝, 下面就只说桌面端支付宝的情况了;

前因后果

目前业务上接入第三方支付的流程是, 前端把商品 id 给后端, 后端返回订单 id 和第三方支付 (支付宝) 相关信息: 一个字符串, 内容为 <form>xxx</form><script>form.submit()</script>, 需要注入到页面内并执行 form.submit(), form.submit() 会将当前页面跳转到支付宝的支付页面; 伪代码如下:

``typescript {1,9} // 商品页面 // 先有的后端接口, 后有的业务流程; 后端接口和业务流程没有前端参与评审; const { orderId, aliHtml } = await post(/api/order/${goodsId}) // 由于当前页面需要保留, 所以只能新页面打开, 并在新页面中处理 form.submit() 相关逻辑 window.open(/pay-redirect?aliHtml=${aliHtml}`, '_blank') // 监听订单状态 (后续在页面中提示订单状态) const result = await listenOrder(orderId)

// 支付页面 (即 /pay-redirect) const { aliHtml } = searchParams injectIntoHtml(aliHtml) ```

相信大家能看出来了, xss 漏洞就发生在 /pay-redirect 页面, 直接从 url 中提取字符串并作为 html 注入到页面中, 是不安全的, 恶意用户可以在参数中添加恶意代码, 并作为钓鱼链接发布, 普通用户点击后就会执行恶意代码。

解决方案

  1. 也是最优方案: 修改业务流程 (后端修改接口), 将获取业务订单发起支付订单分为两个接口, 前端先获取业务订单, 再凭借业务订单来发起支付订单 (由于时间关系, 该方案没有被采纳);
  2. 先修改前端流程, 后续有时间再说: typescript {3} const subWindow = window.open('/pay-redirect', '_blank') const { orderId, aliHtml } = await post(`/api/order/${goodsId}`) // 接口获取到 aliHtml 后, 再通知 /pay-redirect 页面 notice(subWindow, { aliHtml }) // 监听订单状态 (后续在页面中提示订单状态) const result = await listenOrder(orderId)

经验教训

没有, 因为方案评审压根就没拉前端 [卑微]

开玩笑啦, 不是吐槽, 其实还好, 之前一直都是这样, 后端自己内部评一下后端技术方案, 然后出接口文档, 然后前端按接口文档搭页面; 由于 90% 都是再常见不过的业务需求, 张三李四王二麻子谁来写都差不多, 接口文档也都大差不差, 所以一直没有什么问题, 只不过这次的支付可能就是那 10% ?

所以可能经验教训是: 夜路走多了, 总会碰到鬼?

]]>
https://16px.cc/blog/lKUa2x0AJV0Rhttps://16px.cc/blog/lKUa2x0AJV0RMon, 25 Sep 2023 08:20:00 GMT
<![CDATA[个人站点搭建记录]]>

个人最终选择的方案是, 买了个腾讯云轻量服务器, 3 年 288 元,1 核 2G,硬盘 60 GB, 送宝塔镜像, 本地构建 Next.js 项目, 再推送到服务器, 最后执行 ssh 重启。为什么如此选择? 那就要说说其他方案为什么不合适了。

尝试一: 云上构建

云上构建, 最开始我试了 github action 监听 push 后自动触发宝塔 webhook, webhook 执行 git pull 从 github 拉取最新的镜像, 执行构建。结果第一步就折戟了, 服务器拉不到 github, gfw 挡住了。。。

既然 github 拉不到, 那 gitlab 总可以吧? 毕竟 gitlab 不需要梯子的。确实可以, 结果倒在了第二步: 服务器跑不动 Next.js 的构建。我重新试了一下, 空的 Next.js 项目可以 build 完成, 但是随便写点什么东西, 内存就爆了, 直接飙到 100%。

尝试二: 本地 windows 构建

我个人电脑是 windows, 云上是 centos, 其实这个方案我没尝试, 因为肯定失败: 项目依赖 sharp 进行图片相关优化, sharp 是需要在 install 的时候执行跟平台有关的 build 的, windows 上执行的 build, 放到 linux 上运行必然抛错。

尝试三: 本地构建+推送云端

所以我就 ~~本地装了一个跟服务器同一版本的 centos 镜像~~ 用 docker 搭了一个 跟服务器同一版本的 ubuntu 容器, 在里面执行构建, 构建完成后上传到服务器, 然后 ssh 执行项目 reload, 显然这样是可行的, 已经稳定运行了个把月了。部署脚本感觉没什么好说的, 愿意看的可以点 deploy.sh

Q & A

Q: 干嘛要脱裤子放屁,直接本地构建推云上不就好了?干嘛还要 docker 里面构建?

A: prisma binaryTargets 构建和系统有关,& Next.js 图片优化需要 sharp,sharp 跟 install 及 build 时的系统有关,本地构建没用;

Q: 你都用 docker 了,干嘛不直接把 docker 推到云端呢?

A: docker 里面带了一个 node 环境,300多M,即使 base node:alpine 也有200+M;我把里面构建的内容拿出来,才30多M,上传到腾讯云很费劲的好吗,轻一点不好吗

]]>
https://16px.cc/blog/lOIqZGcuWRJphttps://16px.cc/blog/lOIqZGcuWRJpTue, 16 Apr 2024 08:19:57 GMT
<![CDATA[浏览器端 aws 文件上传]]>选择 aws 包

我们去 google awa upload from browser, 总能看见一个包的身影 —— @aws-sdk/s3-request-presigner, 以及一些关键字 —— getSignedUrl or presign, 我遇到了各种各样的干扰:

干扰一: deprecated

如果我们搜这个包 @aws-sdk/s3-request-presigner, 会发现 google 第一条就是 This API Documentation is now deprecated, 经过测试, 要用的就是这个包, 估计那个 deprecated 只是说那个文档 deprecated…

干扰二: 过时文档

还有一些干扰文档, 如:

// aws-sdk 已经过时了
const AWS = require('aws-sdk')

const s3 = new AWS.S3()

const url = s3.getSignedUrl(/* ... */)

aws-sdk 已经过时了, 我们应该用 @aws-sdk 系列包。

排除干扰, 然后照着 @aws-sdk/s3-request-presigner 文档抄就可以了

然而

即使照着抄, 如果你使用的是 pnpm, 仍然会报 warning(虽然并不影响运行), 看 log 会发现它缺少一个包 @aws-sdk/signature-v4-crt, install 就好了。

上码

代码放这, 以供参考

server side

// server side
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import {
  S3Client,
  PutObjectCommand,
  ObjectCannedACL,
} from "@aws-sdk/client-s3";

import type { PutObjectCommandInput } from "@aws-sdk/client-s3";

const s3Client = new S3Client({
  region: process.env.AWS_REGION,
  // 所需其他参数请参考 S3Client 文档
});

type RequestUploadConfig = Pick<File, "type" | "size">;

export async function requestUpload(f: RequestUploadConfig) {
  // 我们可以在这里做鉴权及其他一些限制逻辑
  // ...

  const uploadParams: PutObjectCommandInput = {
    Bucket: process.env.AWS_BUCKET,
    /**
     * Key 实际上是上传的 pathname,
     * 但有一点需要注意, 不包括前缀 '/',
     * 即: 如果你的目标路径是 '/public/dir/file.txt',
     * 那 Key 应该是: 'public/dir/file.txt';
     */
    Key: uploadKey,
    // 我们可以限制用户上传的 type & size, 其他更多限制可以看文档
    ContentType: f.type,
    ContentLength: f.size,
    // 权限任选 public or private
    ACL: ObjectCannedACL.public_read,
  };

  const command = new PutObjectCommand(uploadParams);

  // 把预签名的 url 返回给客户端就好了
  return getSignedUrl(s3Client, command, { expiresIn: 3600 });
}

client side

// client side
const url = await requestUpload({
  type: f.type,
  size: f.size,
});

await fetch(url, {
  // 注意是 PUT, 而非 POST 或其他
  method: "PUT",
  body: f,
}).then((res) => {
  if (!res.ok) {
    throw new Error(`上传失败: ${res.statusText}`);
  }
});

// ok 上传成功

补充 Key 的问题

上面说了 Key 不能带前缀 '/', 因为当你调用 aws 删除命令时, 带 '/' 的话不能成功删除, aws 也不会抛错, 会静默地失败。

await new DeleteObjectCommand({
  Bucket: process.env.AWS_BUCKET,
  // 错误, 这样是删不掉的, 不能带 '/' 前缀
  Key: "/public/dir/file.txt",
  // 正确的 Key 应当是 'public/dir/file.txt'
});
]]>
https://16px.cc/blog/ztv4GbGwZZcthttps://16px.cc/blog/ztv4GbGwZZctThu, 31 Aug 2023 16:46:49 GMT
<![CDATA[简述前端发展脉络]]>

"前端是个娱乐圈, 成天发明各种概念。"
"现在搞的服务端渲染, 那不就是炒 php 的冷饭?"
"前端千辛万苦爬到山顶, 却发现 phper 早已等候多时。"

本篇代码均为伪代码, 且不严谨不完善, 仅为示例

  1. 纯静态 html
   XXX 博客欢迎你
  1. 通用网关接口 CGI
  • 服务器上实现动态网页的通用协议。
  • 一次请求对应一个 CGI 脚本的执行,生成一个 HTML。
  • 不限语言, Perl、Shell 脚本、C 都行。
   print("""
     XXX 商城欢迎你

     <form onsubmit="/buy">
        <input name="count" />
        <button onclick="buy">买买买</button>
     </form>
   """)
  1. js & php
  • js: 客户端执行一些脚本, 一方面节约服务端资源, 另一方面提高用户体验 (不用刷新整个页面);
  • php: 更便捷地混排 server data + html + js;
   欢迎 <?php echo $_POST["fname"]; ?>! <br>

   <script>
   function plus(a, b) {
     alert(`和为 ${a + b}`)
   }
   <script>

   <input name="a" />
   <input name="b" />
   <button onclick="plus">相加</button>

在此之前, 前端大多不能独立部署, 代码组织也受限于后端, "戴着镣铐跳舞"。

  1. ajax

js 能从服务端动态获取数据; 标志着前端有能力从代码层面脱离后端独立部署, 独立完成需求;

   <script>
     function buy(count) {
       fetch(`/buy?count=${count}`)
     }
   </script>

   <input name="count" />
   <button onclick="buy">买买买</button>
  1. MVVM: react / vue / angular 等
  • 原则: 数据驱动视图;
   const unitPrice = 10
   const count = 0

   // 之前
   function onClick(newCount) {
     count = newCount
     const total = unitPrice * count
     countElement.innerText = `个数: ${count}`
     totalPriceElement.innerText = `总价: ${total}`
   }

   // 之后
   const total = unitPrice * count

   function onClick(newCount) {
     count = newCount
   }

   return (
     <div>
       <div>个数: {count}</div>
       <div>总价: {total}</div>
     </div>
   )
  1. (4.1) 前端路由 (单页面)

前端越来越简单, 能做的事情越来越多, 干脆一把梭, "路由也交给前端处理吧"; 前端统一组织代码, 切换页面本质上是组件替换, 能保留子组件状态, 能实现页面转场动画, 跳转还很快, 简直"一本万利"。

   if (pathname === '/a') {
     return <PageA />
   }
   if (pathname === '/b') {
     return <PageB />
   }
  1. 服务端渲染 ssr
  • 单页面的问题: 首次渲染很重 (懒加载可以解决)、SEO 不友好;
  • (Node) 服务端渲染优点:
    • 有些东西在服务端就可以决定, 不需要发送给客户端, 降低传送的体积;
    • 服务端直出 html, SEO 友好;
    • 客户端、服务端语言一致, 类型可以共用;
   // server
   const config = await fetch('/config')

   if (config.aaa) {
     return <div>aaa</div>
   }
   if (config.bbb) {
     return <div>bbb</div>
   }
  1. rsc (react server component)

即使如 ssr, 也需要生成一整个 html, 然后发给客户端, 如果我们能流式传输, 服务端一边处理, 一边响应客户端呢? 那速度不得起飞?

   // 真实代码远不是这样, 这写法甚至是错误的, 只为简述, 理解概念
   const aaa = await fetch('/api/aaa')
   const bbb = await fetch('/api/bbb')
   const ccc = await fetch('/api/ccc')

   return (
     <div>
       <div>{aaa}</div>
       <div>{bbb}</div>
       <div>{ccc}</div>
     </div>
   )
]]>
https://16px.cc/blog/E_J4g1pSNTg2https://16px.cc/blog/E_J4g1pSNTg2Wed, 30 Aug 2023 03:54:29 GMT
<![CDATA[记一次排查服务器内存占用爆满]]>

服务器内存使用率 90%+, 我一个 2G 的服务器就运行一个 Node 和一个 mysql, 不可能占用那么多啊, 平时都是 40% 不到的, 所以需要排查问题

查看内存占用

首先应该查看是谁占用了那么多内存, top -c -o %MEM 可以查看当前系统实时视图

  • -c 表示显示完整信息, 不截断
  • -o %MEM 表示以 %MEM 排序

一看就发现问题了 (忘了截图), 排最前面有六七个 next-render-worker-app / next-render-worker-pages, 每个都占用了 %5 左右的内存, 加起来占了一半多, 原来内存都被你们吃了! 应该是 命令行重启 node 项目 的时候, 需要额外清理这些进程

解决方案

重启项目之前清理掉相应的进程: kill -15 $(ps aux | grep '[n]ext-render-worker-' | awk '{print $2}')

]]>
https://16px.cc/blog/7qvCFp-jBGQghttps://16px.cc/blog/7qvCFp-jBGQgSun, 27 Aug 2023 06:34:14 GMT
<![CDATA[记一次愚蠢的缓存危机]]>问题

今天改了 manifest.json, 推到服务器上后, 一直不生效。看了一下缓存头, Cache-Control: max-age:31536000, must-revalidate, 糟糕。。。

原因

  • 下列所述 "过期" 是指 stale: 客户端综合计算 age + max-age + Date 得出的当前资源是否有效的状态;
  • 源服务器综合考虑 If-Modified-SinceIf-None-Match 后, 回复客户端是否过期 (如果已过期, 会在 body 中返回新的资源; 如果没过期, 就返回 304);
  • 下列所述 "不能" / "必须" / "应该" 都是指服务器如此要求, 但是否执行取决于客户端的实现, 你完全可以自己编译一个浏览器, 忽略请求头, 应该不犯法;
  • no-store 表示不能缓存
  • no-cache 表示可以缓存, 但客户端每次使用都必须与源服务器验证是否有变
  • must-revalidate 表示可以缓存, 但一旦过期, 就不能继续使用该资源, 而是必须与源服务器重新验证才能使用, 如果连不上源服务器, 那你就别用了 (注意, 平时没过期的时候, 客户端是不会去找服务端验证的)
  • stale-while-revalidate 表示即使缓存过期了也还能用, 只要你用了之后重新验证并更新缓存就好了, 但是如果验证出错了 (500 或 404 或其他什么错误), 那你下次就不应该再用那个过期的资源了
  • stale-if-error 表示即使缓存过期了也还能用, 之后重新验证并更新缓存就好了, 万一重新验证出错 (500 或 404 或其他什么错误), 你下次还能用旧的缓存
  • immutable 表示只要还没过期,你就尽管用, 不要来问我服务器, 肯定不会变

不应变化的静态资源可以适用 must-revalidate, 例如不会变化的文件如 vconsole.min.js, 自带版本号的文件如 sensorsdata-20110513.min.js, 这类文件无需打包, 可以直接放在 public 静态目录中, 内容长期不会变化, 如果有变, 我们也会修改版本号, 所以可以适用长期缓存, 且客户端每次使用都无需重新验证。(这种情况其实也能用/更适合用 immutable)

但是像一些可能有变、且不能随便修改文件名的文件, 千万不要使用 must-revalidate (即使要用, 也设一个短一点的 max-age)。如 favicon.ico, manifest.json 等, 应该用 no-cache, 每次用的时候必须验证是否有变。

解决办法

没什么好办法, 只能庆幸这是一个没人访问的私人站点。。。

  1. 手动清理浏览器缓存, 发现还是没用, 然后发现 Response Headers 中有另一个 header: X-Cache: HIT, 所以:

  2. 清理 nginx 缓存

OK, 勉强搞定

后记

最后, 如果真的有用户访问的项目遇到了这样的问题, 我们没办法一个一个去清理用户的浏览器, 那么我们能怎么做呢?

  1. Deleting stored responses 说, 可以给服务器发个同 url 的 POST 请求

One of the methods mentioned in the specification is to send a request for the same URL with an unsafe method such as POST, but that is usually difficult to intentionally do for many clients.

  1. 同样是 1 中的链接说的, 使用 Clear-Site-Data: "cache" 标头 (但是并非所有浏览器都支持该标头, 且该方法只能清空浏览器缓存, 无法处理中间缓存 intermediate caches)

总结来说, 就是谨慎点, 别搞出这样的事, 给自己找麻烦。

]]>
https://16px.cc/blog/tIUWujf42LL0https://16px.cc/blog/tIUWujf42LL0Sat, 26 Aug 2023 19:46:54 GMT
<![CDATA[宝塔命令行重启 node 项目]]>1. 终止进程

linux 一步到位解除端口占用

# {PORT} 指项目运行的端口号
kill -15 `lsof -t -i:{PORT}`

2023-08-27 append: 需要额外清理旧的进程, 详见 记一次排查服务器内存占用爆满

kill -15 $(ps aux | grep '[n]ext-render-worker-' | awk '{print $2}')

2. 重新启动项目

宝塔问答: 如何重启可视化 node 项目

# {PROJECT_NAME} 指你自己待重启的项目名
bash /www/server/nodejs/vhost/scripts/{PROJECT_NAME}.sh
]]>
https://16px.cc/blog/LXf9GP8BHrb9https://16px.cc/blog/LXf9GP8BHrb9Sun, 27 Aug 2023 06:39:20 GMT
<![CDATA[linux 一步到位解除端口占用]]>查看占用端口的进程 id (即 PID)
# 此处的 {PORT} 指你需要查看的端口号
lsof -t -i:{PORT}

# 示例
lsof -t -i:3000

终止进程

# kill -15 让进程正常退出
kill -15 {PID}

# kill -9 表示强制中断, 不推荐使用, 只有当进程发生异常, 自身无法正常退出时, 才使用
kill -9 {PID}

一步到位

kill -15 `lsof -t -i:{PORT}`

如果你想终止某个命令

假定命令名是 next-render-worker-xxx

kill -15 $(ps aux | grep '[n]ext-render-worker-' | awk '{print $2}')

]]>
https://16px.cc/blog/9FyGrXBK__jJhttps://16px.cc/blog/9FyGrXBK__jJSun, 27 Aug 2023 06:47:01 GMT
<![CDATA[关于我]]>

都是闲聊, hr 可以不用浪费时间在这里

我是谁

  • 一个写了几年前端的人
  • 稍微了解一丢丢的后端和运维
  • 只想写 ts / react
  • 不喜欢 vue,不喜欢小程序。个人爱好,没有原因

我是怎么走上前端的

2017 年 (也可能 16 年底? 不记得了) 我开始自学前端, 那时候一股脑扎进来, 什么都不懂, 甚至都不懂找教程。那时候的目的甚至不是工作, 只是感觉要学点什么, 恰好前端写了就能看到结果, 就学了。

当时买了一本红宝书《JavaScript 高级程序设计》, 照着敲, 忘了敲了多久了, 反正差不多敲完了一本书, 然后就到处找 demo 做, 其中有 2 个印象最深刻的, 一个是阿里某位大佬 (后来才知道是阿里的, 名字忘了) 的网站 http://www.fgm.cc/learn/ , 现在已经闭站了, 里面有很多入门级例子, 可以实现。另一个是 百度前端技术学院, 这是我唯一高度评价的百度的产品, 里面也有很多实战的项目, 并且由浅入深, 十分友好, 17 年及之前的题目都可称得上十分精良。可惜自从 vue 火了以后, 百度也想蹭一把热度, 强推自己的 mvvm 框架 san, 18 年 (也可能是 17 年年中?) 的整个站点项目全都跟 san 有关, 其后也慢慢消亡了。

17 年 4 月份的时候实现了我的第一个博客, 可以说标志着我从此走上前端这条路

2017年博客.jpg

现在来说十分粗糙, 生成文章是靠"批处理"来做的, 也没有服务端的概念, 全部都是一股脑 html + css + js, 也几乎没有用库的概念, 几乎全部功能都是 js 手写的, 所以里面的 复制本文链接 其实是没用的, 因为我当时不知道怎么复制文本, 也没想过去网上搜索答案…

我的前端路线摸索

17 年 vue 刚开始火, 那时候就开始学 vue + webpack, 那时候到处是 webpack 的脚手架, 就尝试着自己从零搭脚手架, 用以学习 vue 和 webpack。但是我比较笨, vue 的 slot 总是搞不明白怎么用 (我的我的, 才知道 slot 是 web component 规范的一部分), @click="func" 还是 @click="func()" 总是搞混, 我这人很不喜欢背东西, 可是 vue 总是有层出不穷的东西让我背…

后来 (忘了什么时候了) 接触到了 react 之后, 立刻就被 react 俘虏了, 这就是 js 啊, 没有任何新东西, 都是 js, 都是函数, 只有入参和出参。然后我就只做 react 了。

18 年入职了第一家公司, 做 3d 相关的, 想搞一点 webVR, 把我招进去了, 那时候学上了全景, 开始用 krpano, 我用得很不爽, 根本就没有一点 js 的风格。我就寻找替代品, 找上了 three.js, 这才是前端该用的东西嘛。但是那家公司不喜欢 three.js, 所以次年我离开了那家公司, 入职了我的第二家公司, 做汽车全景内饰改装的。(顺带一提, 第一家公司在我离开后三四个月就倒闭了, 当然跟我的离开没有关系 hhh)

19 年入职的第二家公司, 在我入职之前也是用 krpano 做全景, 我入职后也做了几个, 由于需要使用 xml 来书写 krpano 私有的语言, 也没有自动化工具, 效率奇低, 一天只能生成几辆车, 我入职之后, 自然就"不能惯着他们"了, 花了 2 个月重新实现了一整套, 从 three.js 前端到 python 自动化工具, 然后就舒服了, 只要电脑跑得过来, 车子随便你上, 三四个设计忙不过来了 hh

说实话, 自从我实现了那一整套之后, 我的工作就变成了优化前端界面和 "自动化工具操控者", 收收文件, 点点鼠标, 无聊得紧, 我感觉他们不需要我了, 我的工作, 随便一个运营的同学来也能搞定, 还能比我搞得更精细更好。只是自那之后是"不可言说的三年", 所以 20 年我又在那家公司呆了一年, 混日子的一年。在那一年, 我意识到 three.js 其实不是前端, 而是数学, 而我, 承认我的数学不足以驾驭它, 因此, 转向普通前端吧。

21 年, 入职了第三家公司, 普通的前端工作, 职责是为公司内部提供营销的技术支持。说得好听, 其实就是开发一些活动页, 无聊, 但有钱… 做的过程中, 我就意识到, react 很好, 可是貌似不太适合活动页, 单页面太重了… 可是没等我做什么, 仅仅呆了 4 个月, 公司裁员, 把我裁了, 因为 "双减", 而公司是当时青少年编程培训的领头羊, 首当其冲…

被裁了之后, 跟着公司另一个项目组, 另立门户, 成为了现在这家公司的一员。基于之前的认识, 我决定用 next.js, 做服务端渲染, 并一直做到现在。

我对前端的看法 (大言不惭)

下面谈论的都是传统的前端 (像我这样的页面仔), 其他如编辑器/web 3d 等, 我搞不来, 就不妄言了。

前端的现状 (我看到的)

  • 随着前端越来越复杂, 前端人员也需要掌握一定的部署知识 (不用太多, 太多就成运维了hh)。

  • 低代码在未来或许会吃掉一 (小) 部分低端岗位, 但不是现在。

  • 社会上仍然存在大量前端基础岗位 (月薪 20k 以下), 也就是传说中的 "页面仔 (指我自己)", 虽然面临了一定的 ai 带来的冲击, 但较为微弱, 大多是开发者出于提高自身效率的自发行为, 未有颠覆性的效率工具冲击现状。但同时由于前些年前端培训的大力发展, 人员供应过于充沛, 且受当前经济形势影响, 因此基础岗位竞争较为激烈。

  • 仍处于基础岗位上的开发者需要有危机意识, 一方面需要提高自身技术水平, 另一方面建议积极寻求跨界的机会, 如智能家居、车机等。

前端的未来 (我认为的)

  • 短期 (未来 5 - 10 年) 来看, 社会发展、技术水平应该不会发展到, 不需要前端花力气优化、网页尺寸/性能无关紧要的地步。基于我个人的体验, 服务端渲染的页面性能、用户体验远好于传统单页面, 尤其是首屏体验。因此, 服务端渲染会在之后的几年越来越流行, 并成为顶峰时期的 jquery/php。(很难说是好还是坏)

  • 一方面服务端渲染对开发者的综合能力要求更高, 包括需要了解服务端和客户端的差异 (虽然是基础知识, 但之前其实不了解也能写前端), 包括组织代码也会比之前更复杂; 另一方面 ai 飞速发展; 两方综合之下, 未来几年将会减少大量前端基础岗位。

  • 随着社会的发展, wasm 将不再如现在这么 "重"; 同时随着语言的发展, 前端的门槛将越来越低, 其他语言的使用者将会更容易地 "侵入" 前端。"纯粹" 的前端将会在服务端渲染技术到达顶峰后, 面临长期的下坡路。未来是综合开发者的天下 (此处的 "综合" 并非是说技术上的 "全栈", 而是说综合性的、跨行业的)。

]]>
https://16px.cc/blog/3EpPJTM2LwB_https://16px.cc/blog/3EpPJTM2LwB_Sat, 16 Mar 2024 18:21:51 GMT
<![CDATA[我给弹窗添加了支持物理返回键 二]]>背景: 书接上文

上文说到, 如果页面上多个弹窗并存时, popstate 事件会在所有弹窗中触发, 导致一个 popstate 事件关闭掉所有弹窗, 那我们的问题就是, 如何管理 history 栈, 让其逐个触发呢?

实现思路

既然所有的弹窗都在监听 popstate, 那我们维护一个队列, 弹窗出现时往队列放入一个特征值, popstate 事件时, 弹窗根据队列尾部的值, 决定自身是否需要响应 popstate;

image.png

show me your code

首先我们实现一个管理 index 的机制

export class IndexManager {
  private stack: number[] = []

  get latest() {
    // 是最大值 而非 最后一个元素, 因为 stack 不一定是 有序的
    return Math.max(...this.stack) ?? 0
  }

  push(n = this.latest) {
    this.stack.push(n)
  }

  drop(n = this.latest) {
    this.stack = this.stack.filter((k) => k !== n)
  }

  has(n: number) {
    return this.stack.includes(n)
  }
}

然后就是最终实现了:

import { useListen } from './useListen'

import { IndexManager } from '@/utils/IndexManager'

import { useEventCallback } from '@mui/material'
import { useRef } from 'react'
import { useEvent } from 'react-use'

let ignoreIdx = -1
// 这个 manager 既非 stack, 也非 queue,
// 叫 stack 只是随便叫的
const stack = new IndexManager()

/**
 * 该 hook 可用于任何需要物理返回键的地方;
 * 注释中的所有 modal 或 弹窗 都不仅限于 "弹窗",
 * 任何需要的地方都可以用,
 * 如 Dropdown/Drawer/Tooltip 等, 任何有 visible change 的地方都可以用;
 * (其实没有 visible change 的地方也可以用, 毕竟该 hook 只负责管理 history, 不干涉 ui)
 */
export function useInjectHistory(
  open: boolean,
  /**
   * 如果需要在用户物理返回时关闭弹窗, 就在该方法中手动调用 modal.hide();
   * 如果拒绝关闭弹窗, 就别 hide() 并 throw Error;
   */
  onPopState: (e: PopStateEvent) => Promise<void>
) {
  // 弹窗维护各自的 index
  const IndexRef = useRef(-1)

  const finalOnPopState = useEventCallback((e: PopStateEvent) => {
    // 轮到我了吗? 没轮到就返回
    if (IndexRef.current !== stack.latest) {
      return
    }
    // 是上一个弹窗关闭而触发的 popstate 吗? 是就返回;
    // (并且把 ignoreIdx 标志位清掉, 使得下次 popstate 能正常生效)
    if (IndexRef.current === ignoreIdx) {
      ignoreIdx = -1
      return
    }
    stack.drop(IndexRef.current)
    onPopState(e).catch(() => {
      // 抛错时, 恢复 stack
      stack.push(IndexRef.current)
      window.history.pushState(null, '', window.location.href)
    })
  })

  useEvent('popstate', finalOnPopState)

  useListen(!!open, () => {
    if (open) {
      // modal index 初始化
      IndexRef.current = stack.latest + 1
      stack.push(IndexRef.current)
      window.history.pushState(null, '', window.location.href)
    } else {
      if (stack.has(IndexRef.current)) {
        // stack 内有该 modal index, 说明是其他地方手动调用 modal.hide, 而非物理返回所触发
        stack.drop(IndexRef.current)
        // 此时需要先清理 stack, 然后根据新 stack 设置标志位
        ignoreIdx = stack.latest
        // 设置标志位是为了避免下一个 modal 被此处触发的 popstate 关闭
        window.history.back()
      }
      // 不管怎么样, modal close 的时候都需要还原该 modal index
      IndexRef.current = -1
    }
  })
}

usage

``` typescript {4-6,14-16} function TempA() { const modal = useModal()

useInjectHistory(modal.visible, () => { modal.hide() })

return <> }

function TempB() { const [open, setOpen] = useState(false)

useInjectHistory(open, () => { setOpen(false) })

return { setOpen(false) }} > body } ```

]]>
https://16px.cc/blog/CLc499WyaT7Khttps://16px.cc/blog/CLc499WyaT7KWed, 08 Nov 2023 14:22:38 GMT
<![CDATA[我给弹窗添加了支持物理返回键 一]]>背景介绍

我们知道, 对于 app 端的弹窗, 物理返回键可以关闭弹窗, 但是在 web 端, 按物理返回键, 直接就返回上一页了。

返回键关闭弹窗, 无疑交互体验更好, 那么怎么实现呢?

首先向大家推荐一个好用的弹窗管理库: NiceModal, 下列代码都是基于该库以及 mui (大多是伪代码, 当然可以不用库或者使用其他库)

利弊分析及思路分析

基本实现思路

因为返回键会改变 history, 所以我们的实现思路肯定是通过监听 popstate 来关闭弹窗:

  • 打开弹窗时 history.pushState()
  • popstate 事件发生时关闭弹窗
  • 同时当我们手动关闭弹窗时, 需要 history.back() 恢复 history

好处

  • 交互体验更好

坏处

  • 由于需要 history.pushState()history.back(), 会破坏用户的浏览记录
  • 用户本来的浏览记录从 A 页面跳转 B 页面, 再返回 A 页面, 此时用户本来可以通过浏览器的 "前进 (forward)" 按钮回到 B 页面的, 如果我们在 A 页面执行 history.pushState() 的话, 用户就无法通过 back / forward 回到 B 页面了

期望的调用方式

暂且将我们的方法命名为 useInjectHistory

// 声明
const TestModal = NiceModal.create(() => {
  const modal = useModal();

  useInjectHistory(modal);

  return <Dialog>test modal</Dialog>;
});

// 调用
NiceModal.show(TestModal);

但是调用方需要有阻止弹窗关闭的能力, 也就是说 "弹窗关闭" 需要放在外部, 即:

// 声明
const TestModal = NiceModal.create(() => {
  const modal = useModal();

  useInjectHistory(modal, () => {
    modal.hide();
  });

  return <Dialog>test modal</Dialog>;
});

// 调用
NiceModal.show(TestModal);

实现思路

export function useInjectHistory(
  modal: NiceModalHandler<Record<string, unknown>>,
  /**
   * 如果需要在用户物理返回时关闭弹窗, 就在该方法中手动调用 modal.hide();
   * 如果拒绝关闭弹窗, 就别 hide() 并 throw Error;
   */
  onPopState: (e: PopStateEvent) => Promise<void>
) {
  // mui 的 useEventCallback
  const finalOnPopState = useEventCallback((e: PopStateEvent) => {
    isTriggeredByPopStateRef.current = true;
    // 当 popstate 发生时, 如果 onPopState 抛错 (即调用方拒绝关闭弹窗),
    // 那么此处重新 history.pushState, 以恢复 history 栈
    onPopState(e).catch(() => {
      window.history.pushState(null, "", "#dialog");
    });
  });

  // react-use 的 useEvent (也可自行使用其他方法, 反正就是监听 popstate, 然后执行 onPopState)
  useEvent("popstate", finalOnPopState);

  // useListen 见下文所述
  useListen(modal.visible, () => {
    if (modal.visible) {
      // 弹窗打开时, push history
      window.history.pushState(null, "", "#dialog");
    } else if (!isTriggeredByPopStateRef.current)
      // 其他地方 (非 popstate 事件) 触发弹窗关闭时, 恢复 history 栈
      window.history.back();
  });
}

useListen 见本站文章 一个监听变量变化的语法糖


2023-08-10 更新:

上面的实现有一个问题: 如果我页面上多个弹窗并存时, popstate 事件会在所有弹窗中触发, 导致一个 popstate 关闭了所有弹窗, 如何解决这个问题呢? 且听下回分解

]]>
https://16px.cc/blog/njC-AH0TX131https://16px.cc/blog/njC-AH0TX131Thu, 10 Aug 2023 08:20:12 GMT
<![CDATA[一个监听变量变化的语法糖]]>由来

在实践中, 我们(我)经常会遇到, 当某变量变化的时候, 执行其他方法(副作用), 通常我们使用 useEffect:

function useHook() {
  const [varA] = useState();

  useEffect(() => {
    // do something
  }, [varA]);
}

但是如果执行的方法中包含其他响应式变量 varB, 我们需要 varB 的最新值, 但却不希望 varB 变化时触发 useEffect 重新执行, 那我们可能需要 hack:

function useHook() {
  const [varA] = useState();
  const [varB] = useState();

  // mui 的 useEventCallback
  const effect = useEventCallback(() => {
    doSomething(varB);
  });

  useEffect(() => {
    effect();
  }, [varA, effect]);
}

实现

因此我们可以写一个小小的语法糖来简化这一目的:

import { useEventCallback } from '@mui/material'
import useEnhancedEffect from '@mui/material/utils/useEnhancedEffect'
import { useRef } from 'react'

/**
 * @example
 * ``` tsx
 * function Component() {
 *   const [count, setCount] = useState(0)
 *
 *   // will be triggered when count changed
 *   useListen(count, (next, prev) => {
 *     console.log(prev, next)
 *   })
 * }
 * ```
 */
export function useListen<T>(
  value: T,
  callback: (next: T, prev: T | undefined) => void
) {
  const isFirstCallbackRef = useRef(true)
  const prevRef = useRef<T | undefined>(undefined)
  const callbackRef = useEventCallback(callback)

  useEnhancedEffect(() => {
    // useEffect 在 dev 环境会执行 2 遍, 此处避免该行为造成的影响
    if (value === prevRef.current && !isFirstCallbackRef.current) {
      return
    }
    isFirstCallbackRef.current = false
    callbackRef(value, prevRef.current)
    prevRef.current = value
  }, [value, callbackRef])
}

用法

``` typescript {4} function Test() { const [varA, setVarA] = useState()

useListen(varA, (next, prev) => { console.log({ next, prev }) })

// … }

### 利弊分析

#### 优势

- 语义明晰
   - 就是为了监听变量变化而生

- 免除各种冗余的格式代码

#### 不足

- 不应用于添加事件监听等

typescript useListen(varA, () => { // 错误用法, 因为没有消除副作用 (removeEventListener) window.addEventListener('event-name', callback) }) ```

]]>
https://16px.cc/blog/QGMtI1cUUbNBhttps://16px.cc/blog/QGMtI1cUUbNBThu, 11 Jan 2024 05:18:40 GMT
<![CDATA[注意, setTimeout 有最大延时值, 溢出就会被立即执行]]>我们有时候使用 setTimeout 会简单粗暴地使用外部传入的值, 如下:

async function sleepMs(ms: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, ms);
  });
}

这样通常情况下没问题, 但是如果输入的数值过大, 会出现意想不到的 bug.

我们知道 js 中, Number.MAXSAFEINTEGER2^53 - 1, 但是 setTimeout 并不支持那么大的数。

https://developer.mozilla.org/zh-CN/docs/Web/API/setTimeout#%E6%9C%80%E5%A4%A7%E5%BB%B6%E6%97%B6%E5%80%BC

浏览器内部以 32 位带符号整数存储延时。这就会导致如果一个延时大于 2147483647 (即 2^31 - 1) 毫秒(大约 24.8 天)时就会溢出,导致定时器将会被立即执行。

所以, 如果数值是外部传入的, 建议函数内做一个简单的判断 (过滤):

async function sleepMs(ms: number): Promise<void> {
  // 至于如何处理 NaN, 则可以根据业务, 自己决定是抛错还是赋默认值
  if (Number.isNaN(ms)) {
    throw new Error("invalid input: NaN");
  }
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve();
    }, clamp(ms, 0, 2 ** 31 - 1));
  });
}
]]>
https://16px.cc/blog/IClO74MRNFmShttps://16px.cc/blog/IClO74MRNFmSThu, 27 Jul 2023 17:02:53 GMT
<![CDATA[操作异步函数的两点建议]]>这儿是工具函数
async function sleepMs(n: number) {
  return new Promise((resolve) => {
    setTimeout(resolve, n);
  });
}

async function asyncFunc() {
  await sleepMs(500)
}

async function randomAsyncFunc() {
  await sleepMs(Math.random() * 1000)
}

忠告一: 异步函数如果有顺序要求, 则最好 await 之

// 此处, 你能幸运地得到顺序打印的 1 2
async function main() {
  asyncFunc().then(() => {
    console.log(1)
  })

  asyncFunc().then(() => {
    console.log(2)
  })
}

// 此处, 只有老天才能知道 1 2 的打印顺序了
async function main() {
  randomAsyncFunc().then(() => {
    console.log(1)
  })

  randomAsyncFunc().then(() => {
    console.log(2)
  })
}

// 因此: 如果对顺序有要求, 最好 await 之
async function main() {
  await randomAsyncFunc().then(() => {
    console.log(1)
  })

  await randomAsyncFunc().then(() => {
    console.log(2)
  })
}

忠告二: 谨慎在异步函数中操作引用变量

async function anotherAsyncFunc() {
  const arr = []

  sleepMs(500).then(() => {
    // 在异步函数中操作引用变量需要谨慎 (这是错误用法)
    arr.push(42)
  })

  return arr
}

async function main() {
  const res = anotherAsyncFunc()

  // 这儿打印的结果是 []
  console.log(res)

  await sleepMs(1000)
  // 这儿打印的结果却是 [42]
  console.log(res)
}

先解释一下上面的代码, 就是由于 arr 是引用变量, 所以, 返回值 res 开始是空数组, 等睡了 500 ms 后, arr.push(42) 被执行, 导致返回值被修改, 此后 arr 变成了 [42]

你可能会说, 谁会写出这么傻的代码, 我只能说有则改之无则加勉, 操作异步函数需要谨慎, 最好是等待其 await, 如果特殊场合不允许, 则尽量别在其内操作指针或执行有顺序要求的动作, 一定要这么做的话, 确保自己能 hold 住

]]>
https://16px.cc/blog/xhh7Oqi2-JXRhttps://16px.cc/blog/xhh7Oqi2-JXRFri, 28 Jul 2023 16:18:01 GMT
<![CDATA[小秀一下站点评分哈哈哈]]>移动端-首页

移动端-首页.png

移动端-博文页

移动端-博文页.png

桌面端-首页

桌面端-首页.png

桌面端-博文页

桌面端-博文页.png

]]>
https://16px.cc/blog/-hg7STBVhTqthttps://16px.cc/blog/-hg7STBVhTqtThu, 18 Jan 2024 15:59:41 GMT
<![CDATA[博客问题汇总]]>一些说明
  • ~~网站这么慢的原因~~

  • 不存在了, 搬到国内了, 速度上来了

  • 懒得搞 api, 直接使用的 rpc 形式的 server action, 导致页面 cache-control 用不上, 始终是 no-store, 页面没法静态化, revalidate 失效;

    • 这个问题不想解决, 因为没人给我写后端, 而 next api 太孱弱, 新开一个 nest 项目又太麻烦, rpc 又太香了。。。
  • 选用了 aws 海外版, 只部署了首尔一个节点, ssl 握手就要花好几秒。穷就一个字, 我只说一次;

    • 这个问题理论上换到国内部署就能解决, 而由于 aws 有 amplify 便于部署, 所以也不想解决。。。
  • 偶尔会显示 服务器错误 是什么鬼

  • 大概率是超出了数据库最大连接数;

  • 数据库免费时长只有 750小时, 所以只开了一个实例;

  • 因为选用了免费的 aws mysql db.t3.micro 数据库, 1gb ram, 2 vCPUs, 最大连接数为 60, 而 Next.js Link viewport prefetch 导致并发请求, 由此导致数据库并发连接过高, 超出 max_connections;

  • 服务端对 5xx 错误做了过滤, 统一显示 "服务器错误, 请稍后再试";

  • ~~待采取的措施是: unstablecache + 一个很长的过期时间 + 修改内容后自动更新相应的 unstablecache 的 tag;~~

  • 已采取措施: 前台页面加 unstable_cache + 一个适当的过期时间, 以减少访问数据库;

  • 参考 prisma 的建议, connection_limit 改成 1

  • 取消使用 next/link prefetch

todos

  • [ ] ~~通知从钉钉机器人改到电报机器人 (本来白嫖的公司的钉钉机器人,可是不知道是不是钉钉收紧了,直接 over limit 了。。。)~~ (傻乎乎的,没有梯子,怎么用电报机器人。。。)
  • [ ] 架一个梯子
  • [ ] 自动生成描述文本 (真的需要吗?)
  • [ ] 编辑的缓存
  • [ ] 账号过期机制
  • [ ] pre > code 添加复制按钮
  • [ ] api 日志
  • [x] bf cache not working
  • [x] 全站音乐(页面跳转音乐不断)
  • [x] 全站字幕(页面跳转字幕不断)
  • [x] ~~tagList 加个背景,与其他部分分隔开~~(不需要)
  • [x] 在自己的博文加上"编辑"按钮
  • [x] sitemap
  • https://nextjs.org/docs/app/api-reference/file-conventions/metadata/sitemap#generate-a-sitemap
  • https://claritydev.net/blog/nextjs-dynamic-sitemap-pages-app-directory
  • [x] 添加 robots
  • [x] 添加 rss (参考: https://taoshu.in/webfeed/lets-webfeed.html)
  • [x] 标题(h1-h6)增加 hash & id, 支持点击修改 url
  • [x] ~~新增 TODO/road map 页~~ (不需要了, 放在这个博文里)
  • [x] 优化深色模式的实现逻辑(现在初次渲染时闪屏太严重了)
  • [x] 部署问题 (用了 aws, 一键解决了)
  • [x] ~~nginx~~(不需要了)
  • [x] ssl
  • [x] 优化 a11y 及键盘导航
  • [x] 上传功能
  • [x] 非 admin 用户限制上传数量和文件大小 (注: 不支持非注册用户上传文件)
  • [x] 文件上传支持图片预览 (因为上传的主要内容就是图片)
  • [x] 支持粘贴式上传文件 (全站任意地方粘贴文件触发上传)
  • [x] 支持拖拽式上传文件 (全站任意地方 drag and drop 触发上传)
  • [ ] 优化上传的交互 (如上传时的空状态等)
  • [x] 上传弹窗的关闭按钮改为完成
  • [x] react server component streaming with suspense
  • [x] ~~pwa 消息订阅与推送~~ (不用 pwa 了)
  • [x] 前端路由时, 页面 title 不会变化; ( 这儿说解决了, 解决个毛线… )
  • 解决方案: 额外添加了前端修改 title
  • [x] 内容页的编辑按钮直接打开编辑弹窗
  • [x] 博文编辑完成后 revalidateTag
  • [x] 需要配置 next-image config, 考虑下是否需要 next/image
  • [x] 尝试把 title 放到article 中 (主要是考虑 UI)
  • [x] 401 默认弹出登录弹窗
  • [x] 统一弹窗 UX (存在部分弹窗关闭按钮在左侧)
  • 目前统一在右上角, 除了博文编辑, 右上角放保存和预览按钮, 放不下关闭按钮了
  • [x] media 新增 loading 态
  • [x] 弹窗支持物理返回键 (物理返回键功能实现见 我给弹窗添加了支持物理返回键 一)
  • [x] 图片预览 (也支持物理返回键)
  • [x] 考虑一下网站搬到国内服务器
  • [x] s3 上的资源加缓存头
  • [x] 自动生成博文目录
  • [x] 固定图片尺寸 (可选方案: 尺寸信息放在 url 上)
  • [x] ~~添加谷歌收录 (国内域名本来就做了)~~
  • [x] 自动评估阅读时长
  • [x] ~~新增 about me 页 (不需要, 直接放博文里吧)~~
  • [x] 全站搜索功能 (当前支持博文搜索)
  • [ ] 待支持功能搜索、页面搜索
  • [x] 新增 404 500 页面
  • [x] ~~回复功能 (站点在国内, 估计上不了这个功能了)~~
  • [x] 一次性链接 (支持访问密码 + 访问时间限制 + 访问次数限制)
  • [x] 文章底部统一展示版权声明
  • [x] ~~资源管理页 (管理个毛线, 不需要了)~~
  • [x] 添加友链页面
  • [x] 添加留言页面 (单向, 给站长留言, 其他人看不到)

bugs

  • [x] !!! 高危 !!! server action 缓存和 service worker 发生了化学反应, 具体原因待查; 表现是, 版本更新后, 本地访问到了旧的页面, 旧的页面引用旧的 js, 然而旧的 js 资源已经失效了, 导致页面崩溃
  • 不用 pwa 了, html 也不用缓存了, 所以不会有这个问题了
  • [x] tags 上面的数量,需要仅计算 published 的博客
  • [x] 不可见博客的推荐阅读,第一条就是它自己
  • [x] 博客编辑中,默认没有填充标签
  • md mui Autocomplete 自己有 bug, Chip 没加 key, 导致控制台一直报红色 warning, 很烦
  • [x] tag/hash 页 tagName 颜色有误
  • [x] mui Stack 注意换行问题
  • [x] 在 pwa 应用中通过 target _blank 打开站内链接偶现直接展示出了 post response
  • 原因: 不是 post response, 是 get response, 因为在 next.config.js 中为页面设置了 cache-control, 浏览器直接读取了本地缓存…
  • 解决方案: 页面不缓存了…
  • [x] ~~pwa 封面图背景色有误~~ (不用 pwa 就不管了)
  • [x] revalidateTag 更新内容后, 本地 pwa 没有更新, 导致刷新页面和前端路由切换页面时的数据不统一
  • 不用 pwa 了
  • [x] /blog/[hash] 页面缓存仍有问题
  • unstable_cache 博客修改之后要更新一堆的东西, 考虑一下别用了
  • 国外接口耗时太久, 站点放到国内就没有这个问题了
  • [x] 登录过期导致的 401 未能弹出登录弹窗
  • [x] 更新博客后的 router.refresh() not work
  • 国外接口耗时太久, 站点放到国内就没有这个问题了

解决方案

疑问

  • 数据库的连接是怎么回事? 是不是我只要从数据库取一次东西, 就会占用一个连接?
  • 答: 部分正确, 查询完成后, 会将连接释放到连接池中可供后续请求使用; 所以当有并发请求时,数据库连接可能会过高','博客问题汇总','PUBLISHED','本博客待新增的 feature、待解决的 bug、已解决问题的解决方案的一个汇总
]]>
https://16px.cc/blog/MlShKQJhLUEThttps://16px.cc/blog/MlShKQJhLUETThu, 29 Feb 2024 03:16:21 GMT
<![CDATA[如何优雅地处理 loading]]>让我们想象一个场景:

“下班顺路买一斤包子带回来,如果看到卖西瓜的,买一个。”

现在,让我们用 react 来实现一下:

```tsx {2,5,17} function GoHome() { const [loading, setLoading] = useState(false)

const goForward = async () => { setLoading(true) const stuff = await lookAround() switch (stuff) { case '包子': await buy(2, '斤', '包子') break case '西瓜': await buy(1, '个', '包子') break default: break } setLoading(false) }

// do something with loading }

这样显然是不行的,因为异步方法可能会抛错,导致 `setLoading(false)` 被跳过,那么我们可能会:

tsx {2,6} const goForward = async () => { setLoading(true) try { // … } finally { setLoading(false) } }

这样实在是不够优雅。

what about this:

tsx {2,4} function GoHome() { const [loading, withLoading] = useLoading()

const goForward = withLoading(async () => { const stuff = await lookAround() switch (stuff) { case '包子': await buy(2, '斤', '包子') break case '西瓜': await buy(1, '个', '包子') break default: break } })

// do something with loading }

以此延伸,我们通常会有这样的需求:点击按钮时发送请求,如果请求超过 500 ms, 再展示 `loading`

tsx {2} function HelloWorld() { return }

实现不复杂,直接看**全部代码**

tsx import { clamp } from 'lodash-es' import { useCallback, useState } from 'react'

const InfiniteTimeout = 2 ** 31 - 1

/**

  • 同一个 withLoading 可以包装多个函数,

  • 同一个 withLoading 包装的所有函数全部执行完毕后才会关闭 loading; *

  • @usage

  • ```tsx

  • function Comp() {

  • const [ loading, withLoading ] = useLoading() *

  • const { data } = useSWR('xxx', withLoading(asyncTask1)) *

  • return <>

  • *

  • <Button

  • onClick={withLoading(asyncTask2)}

  • >

  • async task

  • }

  • ``` */ export function useLoading() { const [flag, setFlag] = useState(0)

    /**

  • @param fn 需要包装的函数

  • @param delayMs 延迟显示 loading (!!! 而非延迟执行函数) */ const withLoading = useCallback( ( fn: (…args: Arg) => Res | Promise, delayMs = 500 ) => async (…args: Arg) => { let timer = -1 if (delayMs > 0) { timer = +setTimeout(() => { timer = -1 setFlag((prev) => prev + 1) }, clamp(delayMs, 0, InfiniteTimeout)) } else { setFlag((prev) => prev + 1) } try { return await fn(…args) } finally { clearTimeout(timer) if (timer < 0) { setFlag((prev) => prev - 1) } } }, [] )

    return [ loading: flag > 0, withLoading, ] as const }

```


后记:代码的实现很简单,但我感觉从代码风格上来说,由原来的命令式,改为了声明式,更 "react" 了,所以感觉很有意义,就此记录。~~(当然不会说是为了水一篇文了)~~

]]>
https://16px.cc/blog/rhCL8zAniBX_https://16px.cc/blog/rhCL8zAniBX_Fri, 12 Jan 2024 01:29:58 GMT
<![CDATA[极简 js 入门]]>需要下载的东西:

编辑器, 写代码用的
sublime / notepad++ 等现代编辑器都行
windows 自带的记事本不行

  • node js 的环境, 是一个命令行工具

起步

  1. 新建一个项目目录

最好起全英文名, 别带中文, 别带空格
例如 hello-worldjs-testhello (分割单词使用中划线 下划线都行, 看自己喜好)

  1. 在刚刚那个目录里面新建一个 js 文件

一般叫 index.js 或者 main.js
用上面下载的 vscode, 别用 windows 自带的记事本

  1. 在里面写些代码并保存

比如说

   console.log('hello world')
  1. 执行

在项目目录下打开命令行工具, 执行 node index.js, 应该就能看到打印出来的 hello world

进阶

  • 去网上找些案例, 跟着做一做
  • 把项目保存到 github(或 gitee), 并使用 git 管理项目
  • 使用 typescript

有用的站点

可以在 https://github.com 上面新建一个自己的账号, 代码可以保存在里面。上面说的 git 就可以用于管理 github 上的项目的。(如果实在没有梯子, https://gitee.com/ 也勉强能用)

大佬

大佬

脚手架, 能帮助我们更快、更便捷地构建项目
建议学习 js 半个月之后再接触

一个前端的框架
如果想要学习前端就可以学学
建议学习 js 半个月之后再接触

]]>
https://16px.cc/blog/9yQot2vnXJVihttps://16px.cc/blog/9yQot2vnXJViFri, 03 Nov 2023 07:54:41 GMT
<![CDATA[小计一次 CLS 优化]]>网站 CLS(Cumulative Layout Shift) 评分一直在 0.7+, lighthouse 评分一直在 80 分上下, 然后就尝试找哪里有问题。

首先运行 lighthouse 评分, 找到 performance 下的 CLS 选项, 看到主要两处影响了 CLS 评分:

  1. 第一处是所有文章的列表, 是一个 MUI Grid 组件

当初文章列表打算做响应式, 就用了 MUI 的 Grid 组件, 后来决定列表不响应了, 但是感觉 Gird 还能用于纵向的 space, 就懒得换了。 可是这是 MUI 啊, 难道 MUI 还能影响 CLS? 不, 我不相信!!!

然而看 CLS 的截图, 明确指向了 Grid 组件, 之前看过 Grid 组件实现原理就是负 margin, 难道是负 margin的问题? 一搜关键字, "mui Grid CLS", "negative margin CLS", 好几个 issue 指向了它:

  1. 第二处是所有标签(tag)的列表, 是一个 object 元素

由于我希望在任何地方, 标签(tag)都能点击跳转到标签详情页(即使在另一个大的 a 标签下面), & 我希望使用原生的 a 标签, 以保证在禁用 js 的情况下仍能跳转, 然而 a 标签不能嵌套, 查了查, 哦, 可以用一个 object 标签包裹住 Tag 组件, 就用了。

CLS 截图指示 Tag 组件影响了评分, 可是它只是一个行内文本, 带了点 padding 和 margin, 它什么也没干啊, 它也没有负 margin, 为什么说它影响了评分啊, 不, 我不相信!!!

可是突然回想起前些天处理 tailwind preflight.css 时, 发现 tailwind preflight 把 object 和 img 放在了一起, 并将他们的默认样式设置为了 display: block;, 会不会, object 和 img 有差不多的性质?

一看 MDN, 果然:

HTML <object> 元素(或者称作 HTML 嵌入对象元素)表示引入一个外部资源,这个资源可能是一张图片,一个嵌入的浏览上下文,亦或是一个插件所使用的资源。

它引用一个外部资源, 那他必然不知道那个外部资源有多大, 而它里面的文字是标签的 name, 有长有短, 就必然导致 object 产生偏移。是啦, 说干就干, 标签 name 固定一下长度, object 也设置一下宽度, 搞定。

重新跑一下 lighthouse, 99 分, 啊, 多么美丽的绿色(是不是…有哪里不对)

]]>
https://16px.cc/blog/9M7HgvNI--R3https://16px.cc/blog/9M7HgvNI--R3Fri, 28 Jul 2023 17:05:36 GMT
<![CDATA[HTML 邮件需要注意的点]]>优秀参考

XHTML 1 规范与 HTML 4 的差异

翻译自 https://www.w3.org/TR/xhtml1/

  1. 文档格式必须正确

    正确: 嵌套元素

   <p>这是强调 <em>段落</em>。</p>

错误: 重叠元素

   <p>这是强调 <em>段落</p>。</em>
  1. 元素名和属性名必须小写

    li 而非 LI

  2. 非空元素必须要结束标签

    正确

    <p>这是一段。</p><p>这是另一段。</p>
    

    错误

    <p>这是一段。<p>这是另一段。
    
  3. 所有属性值必须使用引号

    正确

    <td rowspan="3">
    

    错误

    <td rowspan=3>
    
  4. 不支持属性最小化(Attribute Minimization)

    正确

    <input checked="checked" />
    

    错误

    <input checked />
    
  5. 空元素必须有结束标签

    正确

    <br /><hr />
    

    错误

    <br><hr>
    
  6. 处理属性值中的空白

    根据 XML 属性值归一化 的标准:

    • 去除前导和尾随空白字符
    • 词间的一个或多个空白字符(包括换行符)将替换为一个 space
  7. script 和 style 元素(其实在邮件中是用不上 script 的)

    在 XML 中, <& 被视为标记的开始, 而 &lt;&amp; 会被转义为 <&. 所以如果你不希望他们被转义, 可以用 CDATA 包裹他们, 或者引用外部文档

    <script type="text/javascript">
      <![CDATA[
      ... unescaped script content ...
      ]]>
    </script>
    
  8. SGML 排除项

    SGML 禁止某些元素嵌套, XML 中没有这些禁令, 但仍表示这些元素不应嵌套

    • a 禁止包含其他 a
    • pre 禁止包含其他 img, object, big, small, sub, sup
    • button 禁止包含其他 input, select, textarea, label, button, form, fieldset, iframe, isindex
    • label 禁止包含其他 label
    • form 禁止包含其他 form
  9. 元素的 idname 属性

    HTML 4 为 a, applet, form, frame, iframe, img, map 定义了 name 属性, 同时也有 id 属性, nameid 都是用作片段标识符

    而 XML 仅使用 id 作为标识符, 且必须文档内唯一(id不能重复), 因此出于兼容性考虑, 当上述元素需要定义标识符时, 必须使用 id(而非 name)

    同时需要注意, 上述元素的 name 属性在 XHTML 1.0 中已被正式弃用, 并将在 XHTML 的后续版本中删除。

  10. 属性枚举值

    HTML 4 中, 属性枚举值不区分大小写(如 inputtype 属性, 值 TEXT 等同于 text), 但在 XML 中区分大小写, XHTML 1 中定义为小写

  11. 16 进制实体引用(Entity references)必须使用小写

    &#xnn; 而非 &#XNN;

  12. 空元素的尾随 / 前面要加一个空格

    • <br /> 而非 <br/>
    • 使用 <br /> 而非 <br></br> (因为后者, 用户代理可能给出不确定的结果, 即不同浏览器的解析结果可能不一致)
  13. 内容模型(content model)非空的元素, 别使用 minimized form

    <p> </p> 而非 <p />

  14. 如果 scripts or style sheets 脚本中有用到 <, &, ]]>, -- 时, 使用外部脚本

    XML 允许用户代理删除注释, 因此你想将他们放在注释中来做兼容也是行不通的

  15. 避免在属性值中使用换行符和多个空白字符, 用户代理处理这些情况是不一致的

  16. 同时使用 langxml:lang 时, xml:lang 的优先级更高

  17. 片段标识符

    标识符用于在 URI 结尾来引用元素, 如 #foo 会引用到 标识符的值为 foo 的元素。

    • XML 中以 id 作为标识符, HTML 4 以 name 作为标识符, 建议对支持的元素, 同时使用 idname, 以做到最大兼容。
    • 需要注意, XHTML 1.0 中 a, applet, form, frame, iframe, img, mapname 属性已弃用, 并将在 XHTML 的后续版本中删除。
  18. 在属性值(和其他地方)中使用 & 符号需要转义 (存疑)

    文档表示, 当元素 href 属性带有 & 符号时, 需要转义, 如:

    • 正确: http://my.site.dom/cgi-bin/myscript.pl?class=guest&amp;name=user
    • 错误: http://my.site.dom/cgi-bin/myscript.pl?class=guest&name=user

    我是有些存疑的, 那样还能访问到正确的地址吗? 待测试

  19. 一些在 HTML 文档中合法的字符, 在 XML 文档中是非法的。

    例如, 在 HTML 中, Formfeed 字符 (U+000C) 被视为空白, 在 XHTML 中, 由于 XML 对字符的定义, 它是非法的。

  20. 撇号 不应该使用 &apos; 而应该使用 &#39; > - 命名字符引用 &apos; (撇号 U+0027: ') 是在 XML 1.0 中引入的, 但没有出现在 HTML 中。因此, 应该使用 &#39; 而不是 &apos;, 才能在 HTML 4 用户代理中按预期工作。 > - 我就遇到过, 产品表示部分用户收到的邮件标题直接出现了 &apos;, 体验非常不好, 而 &#39; 是否可以避免这个问题? 待测试

]]>
https://16px.cc/blog/mz9sNneP9r8chttps://16px.cc/blog/mz9sNneP9r8cFri, 28 Jul 2023 17:04:18 GMT