前端

一些在前端工程化上的经验和踩过的坑

#工程化 #记录

[打包] 替换打包产物内容

一个简单的小需求,在打包出的 js 文件里通过正则替换一些内容。这个需求相对简单,不需要 loader,可以用 webpack plugin API 的 compliation hooks 来做。

processAssets hook 中使用 compilation.getAssets() 获取到产物列表,针对产物进行处理即可。以下是一个例子,替换掉产物中的 AMD 判断代码 (typeof define == 'function')。

const amdDetectRegex = /typeof\sdefine\s(=)+\s["']function["']/g;
class RemoveAMDWebpackPlugin {
  apply(compiler) {
    compiler.hooks.compilation.tap('RemoveAMDWebpackPlugin', (compilation) => {
      compilation.hooks.processAssets.tapPromise(
        {
          name: 'RemoveAMDWebpackPlugin',
          stage: compilation.PROCESS_ASSETS_STAGE_ADDITIONS,
        },
        async () => {
          const assets = compilation.getAssets();
          for (const asset of assets) {
            const assetName = asset.name;
            const assetContent = asset.source.source();
            if (assetName.endsWith('.js') && assetContent.match(amdDetectRegex)) {
              const newContent = assetContent.replace(amdDetectRegex, 'false');
              compilation.updateAsset(assetName, {
                source: () => newContent,
                size: () => newContent.length,
              });
            }
          }
        }
      );
    });
  }
}
module.exports = RemoveAMDWebpackPlugin;

[Monorepo] Monorepo 源码引用配置 TailwindCSS

背景

基于开发效率的考虑,准备给我们的 React 客户端项目接入 TailwindCSS.

目前我们项目是以 monorepo 的形式维护,组织形式是一个主应用 + 多个领域包,主应用通过源码引用子包 src/index.ts 导出的内容、使用子包导出的组件等,子应用不进行任何打包操作,在主应用配置好 Rspack 统一打包。原以为装上依赖,配好 tailwind.config.ts,再给 Rspack 装上 postcss 就可以了,但是按照官方流程走完一套下来简单写了几个 utility classes 测试了一下,发现有的部分样式正常,而有的地方没有样式。

只在主应用配置 tailwind

遂开始排查,发现只有写在主应用里的 utility class 生效了,子包里的都没有生效,便怀疑 tailwind config 中的 content 没有生效。官方只配置了 ./src/**/*.{html,js,ts,tsx},所以 postcss 只处理了主应用 src 目录下的源文件,忽略了子包中的源文件。

问题来了,子包中也有 utility class 的情况下,这个 content 怎么写呢。查了一下 GitHub Issues,告诉我应该这样写:

content: [
  './src/**/*.{html,js,ts,tsx}',
  './node_modules/@scope/**/*.{html,js,ts,tsx}',
]

试了一下,果然正常地扫描到子包的 utility class 并且正常打包了样式。这个时候,不出意外的话就要出意外了,接下来发现的第二个问题是,修改主应用和子包的组件,HMR 把组件更新了,但是新的样式没被热更上去。一番排查发现是热更上来的 CSS 文件似乎没有成功应用。

Tailwind HMR 不生效的排查

看起来似乎是 Rspack 的问题?尝试更新了一波 Rspack 和 HMR 的版本,还是一样的问题。但是创建了一份干净的模版,tailwind 是能正常热更的。

经过一番排除法,最终发现是 output 中设置了自定义的文件名生成策略。我们的项目 output 配置是这样的:

output: {
  filename: '[name].[contenthash:8].js',
  path: path.resolve(baseRootPath, './dist'),
},

把它去掉之后就能够正常热更新了。一脸懵逼,为啥 HMR 会和这玩意儿有关系。这个时候才发现 Rsbuild 的文档里有说明,如果输出文件中存在 hash 可能会导致热更时一些插件无法读取到最新的内容:https://rsbuild.dev/zh/guide/faq/hmr

在开发模式下把文件名的 contenthash 去掉,这回热更貌似正常了:

output: {
  filename: process.env.NODE_ENV === 'production' ? '[name].[contenthash:8].js' : '[name].js',
  path: path.resolve(baseRootPath, './dist'),
},

正确配置 content 包含源码路径的方法

正觉得大功告成的时候,突然发现主应用的热更是正常了,可是修改子应用的组件还是不能热更,热更的 CSS 里也没带上子应用里新增的 utility class. 猜测这是个在 monorepo 底下独有的问题,这回在网上搜了半天也没找到是什么原因,盲猜了一波 node_modules 底下的文件是不是会被热更新忽略掉。顶着怀疑重新修改了一下 tailwind.config.tscontent,把 node_modules 下的引用改成了相对路径引用:


content: [
  './src/**/*.{html,js,ts,tsx}',
  '../packages/**/src/**/*.{html,js,ts,tsx}',
]

修改成绝对路径引用之后,修改子包的 utility class 也能够触发 HMR 了。

减少构建耗时影响的小 trick

但是,这样又双叒带来了一个问题,那就是 ../packages/**/src/**/*.{html,js,ts,tsx} 这么配置路径让构建变慢了!!原来只需要 20 秒冷启动构建、300ms HMR,加上这个路径匹配直接拖慢到了 1min cold start / 2s HMR. 这我能忍?

我猜是因为路径里的 ** 通配符太多了导致的,于是决定干掉一层,手动声明一下需要包含的目录:

// not using ** to resolve sources to improve build / HMR performance (4s -> 200ms)
function getTailwindContentIncludes() {
  const appDirectory = resolve(__dirname, '../');
  const packagesDirectory = resolve(__dirname, '../../packages');

  const getSubDirectoryIncludes = (path: string) => {
    const packages = readdirSync(path);
    return packages.map(
      (pkgDir) =>
        `${relative(__dirname, join(path, pkgDir, 'src')).replace(/\\/g, '/')}/**/*.{html,ts,tsx,js,jsx}`
    );
  };

  return [
    ...getSubDirectoryIncludes(appDirectory),
    ...getSubDirectoryIncludes(join(packagesDirectory, 'domains')),
    ...getSubDirectoryIncludes(join(packagesDirectory, 'shared')),
  ];
}

const config: Config = {
  content: getTailwindContentIncludes(),
  // ...
};

干掉了一层 **,手动写扫描路径,把引用路径限制到当前项目的 src/ 和几个特定的目录下 src/。这样改完之后速度果然有很大的提升,HMR 也回到了 400ms。考虑到引入了 postcss 肯定会有一些性能损耗,虽然有一些增长但是仍然在 ms 级别可以接受。(所以 tailwind 什么时候可以用上 lightingcss?)


[Monorepo] rush update-autoinstaller 无法创建 shrinkwrap 文件

问题: 在把 monorepo 迁移到 Rush 、配置 command-line.json 的过程中,通过 rush init-autoinstaller 创建 autoinstaller 、更新 package.json 之后,执行 rush update-autoinstaller 使用 pnpm 更新依赖,报错 The package manager did not create the expected shrinkwrap file.

原因: 需要把 monorepo 根目录下的 pnpm-workspace.yaml 删除,保留这个文件会导致 pnpm 不会在 autoinstaller 的目录下产生 pnpm-lock.yaml. Rush 自带了 pnpm workspace 的支持,不需要再使用 pnpm-workspace.yaml 声明 packages 了。


[安全] 关于 iframe sandbox 和 Content-Security-Policy

今天遇到一个用户提的 oncall,用户在我们的系统底下嵌入了一个 iframe,iframe 内容是用户公司自建的页面。用户的诉求是从子页面拿到顶层页面 (iframe 宿主) 的 URL,然后遇到了跨域问题,用户提问是否可以配置 iframe 属性或者配置 CSP 绕过,把我问住了。

我们来尝试复现一下用户的场景,用户是这样做的:

http://localhost:8080

<html>
  <head>
    <title>Parent Page</title>
  </head>
  <body>
    <iframe src="http://localhost:8081/" />
  </body>
</html>

http://localhost:8081

<html>
  <head>
    <title>Child Page</title>
  </head>
  <body>
    <script>
      console.log(window.top.location.href);
    </script>
  </body>
</html>

不出意外就要出意外地报错了:

Uncaught SecurityError: Failed to read a named property 'href' from 'Location': Blocked a frame with origin "http://localhost:8081" from accessing a cross-origin frame.

很明显问题是出在了 window.top.location.href 的读取(跨源访问)被浏览器的同源安全策略拦截了。根据 MDN 上对 浏览器同源策略 的描述,大部分属性的跨源访问是只读的,唯独对 Location 对象的 href 属性是只写的(实际上大部分的 “跨源读操作” 一般是不被允许的):

同样地从父页面访问子页面的 window 和 Location 对象也是受限的,这种情况下设置 Access-Control-Allow-Origin 也不好使。一般来说这种情况最好的办法是直接通过 iframe src 设置 URL query 来传递父页面 URL,或者需要传递复杂信息的时候使用 window.postMessage.

问题其实到这里解决了,但是用户一开始提出的方案 “配置 iframe 属性或者配置 CSP 绕过” 是否有可行性呢。

iframe sandbox

先说说是否能配置 iframe 的属性来绕过一些浏览器的安全限制。iframe 有一个 sandbox 属性,允许开发者限制 iframe 中页面的行为,如执行 JS、下载文件、提交表单、弹窗等,也可以强制浏览器将一个同源的 iframe 页面视为非同源页面。

但实际上我们直接使用 <iframe src="xxx" /> 这样来嵌入一个 iframe 的时候,不会应用任何限制,所以其实没有必要特地指定属性,对 iframe 和浏览器来说同源就是同源,非同源就是非同源,指定 sandboxallow-scriptsallow-same-origin 限制并不能改变这一点。

Content Security Policy

再说说内容安全策略 (Content Security Policy, CSP)。 其实今天之前我也不知道 CSP 具体有啥用,所以用户提到这个的时候我也有些懵逼。

简而言之,CSP 主要是控制浏览器可以为该页面获取哪些资源,一般我们可以在返回的响应头或者 meta 中设置 CSP:

<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'self'; img-src https://*; child-src 'none';" />

CSP 通过特定的 DSL 来描述访问限制的策略,格式是 key-src domain1 [domain2] ... ;,更多示例可以看 MDN.

  • 必须包含一个 default-src
    • default-src 'self' 表示当前页面引用的所有内容,都只能来自当前页面所在的域名(子域名也不行)
    • 可以使用通配符,指定一个白名单,信任除当前页面域名外的某个域名及其子域名:default-src 'self' *.trusted.com
  • 通过 img-src 限制图片引用、media-src 限制媒体文件引用、script-src 限制脚本引用:
    • default-src 'self'; img-src *; media-src media1.com media2.com; script-src userscripts.example.com
  • 利用 CSP 也可以用来做其它的限制,例如强制使用来自 SSL 的资源:default-src https://xxx.com

CSP 还可以设置为警告模式,并且还能够通过 report-uri 来将违反 CSP 的行为报告给服务器。

总之 CSP 是个比较冷门的功能,可以用 CSP 来减少 XSS 攻击的发生。同样,默认不设置 CSP 的时候,其实也是采取了最宽松的引用限制的。所以它能解决用户的问题吗?答案也是不能。