Gatsby Starter Blog

工程化: 踩坑大全

January 22, 2020

各种库和工具的坑和技巧

babel && rollup && tsc && webpack && tsdx

rollup 常用插件功能

  • rollup-plugin-commonjs: 由于 rollup 只支持 esm 模块,如果需要导入 cjs 模块,需要将 cjs 转换为 esm 模块,但是将 cjs 转成 esm 模块的时候人突然有一些坑如

    • symlink 问题:在 monorepo 项目里软链的情况下会导致 nodemodules 里的模块的真实路径是 packages/xxxlib 这导致不会匹配上 nodemodules 的正则,导致无法命中,就导致无法将 cjs 转换成 esm
    • named exports 问题:只支持module.exports = { named: 42 }或者exports.named = 42转换为const named = 42; export { named },但是并不支持 alias 和 reexports(react 官方的所有库由于支持了 develop|production 的判断导出,所以都有问题)
// mylib/cjs.js
module.exports = { named: 42 }
// mylib/index.js
module.exports = require("./cjs")
// main.js
import { named } from "mylib" // 支持
console.log("named:", named)

这种情况可以通过配置 namedExports 解决

commonjs({
  namedExports: {
    mylib: ["named"],
  },
})
  • rollup-plugin-node-resolve: rollup 默认不支持自动的将xxx解析为node_module 里的xxx模块,我们可以通过该模块将xxx解析为 node_module 里的模块,该模块默认不会 resolve 该 builtins , 如果需要在浏览器 bundle 一些 node 的 builtins 模块,可以通过 rollup-plugin-node-builtins 支持在浏览器 shim nodejs 的 builtins
  • rollup-plugin-json: json 支持
  • rollup-plugin-sourcemap: sourcemap 支持
  • rollup-plugin-terser: 压缩和 uglify 支持
  • rollup-plugin-filesize: 显示 bundle 后的大小
  • rollup-plugin-peer-deps-external: 将 peer depdency 里的模块不进行 bundle
  • rollup-plugin-visualizer: 包大小分析

babel

  • babel-plugin-transform-async-toproimse: 存在 bug
  • babel 开启 loose:true 情况下,会导致[…new Set()]的处理有问题,不支持 Symbol.iterator

tsc

  • tsc 编写库的时候,如果只开启了 allowSyntheticDefault 而没有开启 esModuleInterop 的情况下,而且在代码里使用了第三方库的情况下会导致业务方出错
// yourlib.js
import moment from "moment" // 没开启esModuleInterop的情况下,会被翻译为 exports.default = require('moment')

// app.js
import lib from "yourlib" // 这时候业务会提示moment为undefined

webpack

  • webpack 导入 uglify 的库导致语义出错,在使用@rematch 的时候,rematch 的 browser 指向了 umd,而该 umd 是被 uglify 的,导致 webpack 二次 uglify 的时候出现了错误,应该尽量保证 webpack 导入的是 esm 模块而非 uglify 后的模块

git

使用—orphan 创建新的分支

有时我们想要删除某个仓库的所有历史提交记录,这是就可以使用 orphan 命令

git checkout --orphan newborn master

这时候的 newborn 分支就是一个崭新的分支,拥有 master 的所有文件,但是没有任何记录,我们把这个 branch 提交到远端覆盖远端即可

react

使用 key 刷新非受控组件

之前实现编辑器的时候为了简化编辑器的状态管理使用了非受控组件,即 props 只负责编辑器的初始化,后续的状态都存放在编辑器内部,后来又加了支持草稿的需求,导致需要编辑器支持更新,对于非受控组件实现更新是个较为麻烦的事情,当时是通过componentWillReceiveProps来处理,如果传入的 html 发生变化,则刷新编辑器的内部状态(各种状态重置,比如输入框的 focus 状态,编辑状态,选择区的重置,draft 内容 json)

 componentWillReceiveProps(nextProps) {
    const {
      article
    } = this.props;
    const {
      article: nextArticle
    } = nextProps;

    if(article.html !== nextArticle.html) {
      this.refreshInternalState()
    }
  }

刷新状态的操作是个苦力活,尤其是编辑器内部进一步存在非受控组件的话,常常容易导致状态不一致。 现在回想起来,将整个组件销毁然后重建要简便的多,很幸运的是 react 官方推出了非受控组件的最佳实践,可以通过 key 来控制非受控组件的销毁和重建。如下所示 https://codesandbox.io/s/uncontrolled-state-em7y9

import React from "react"
import ReactDOM from "react-dom"
import { useState } from "react"

import "./styles.css"
class Editor extends React.Component {
  constructor(props) {
    super(props)
    this.state = {
      value: this.props.defaultValue,
    }
  }
  handleChange = e => {
    this.setState({
      value: e.target.value,
    })
  }
  render() {
    return <input value={this.state.value} onChange={this.handleChange} />
  }
}

function App() {
  const [value, updateValue] = useState(1)

  return (
    <div>
      <Editor defaultValue={value} key={value} />
      <button
        onClick={() => {
          updateValue(value + 1)
        }}
      >
        add
      </button>
    </div>
  )
}

const rootElement = document.getElementById("root")
ReactDOM.render(<App />, rootElement)

非 es5 兼容的包收集

由于为了加快便以速度,会不去编译 nodemodules,但是有时使用的第三方库编译后的代码仍然含有 esnext 的 api,如 includes,这会在低端机型上产生问题,但是直接编译 nodemodules 也会带来问题,增大编译时间,和兼容问题(库的代码是使用 babel5 编译)然后再经过业务的 babel6 编译就可能产生问题,因此解决办法是配置一份黑名单,将黑名单的代码经过编译即可。现阶段碰到的有问题的库包括:

  1. @sentry/browser : 使用了 includes
  2. @rematch/immer: 使用了 Object.assign

yarn

  • 缓存禁用
yarn --global-folder ./node_modules/ --cache-folder ./node_modules/
  • 检查禁止修改 yarn.lock
yarn --frozen-lockfile install
  • 忽略 engine 检查
yarn --ignore-engines install

lerna

lerna publish 无意触发 npm script 钩子,导致 lerna publish 报错

image 之前的 npm script 里定义了 version 这个 script,结果 npm publish 的时候触发了 version 这个 script,导致生成了 changelog,导致产生了 uncommit change 导致 npm publish 报错,所以定义 script 时应该避免 npm 内置的 script,放置误触发。常见的 npm 的 lifecycle hook 如下,npm script

  • prepublish
  • prepare:
  • prepublishOnly
  • publish:
  • install |postinstall | preinstall

lerna 在工作中会运行的一些 hook pre publish 阶段会运行 root 的:

  • prepublish
  • prepare
  • prepublishOnly
  • prepack 每个 package 的
  • prepublish
  • prepare
  • prepublishOnly
  • prepack

publish 阶段 每个 package 的

  • publish
  • postpublish

after publish 阶段

  • publish
  • postpublish

python

python 默认的序列化操作不符合规范允许 nan 的序列化导致前端 JSON.parse 挂掉

见该 issue https://stackoverflow.com/questions/6601812/sending-nan-in-json ,一旦后端用了这种序列化,只能自求多福了

koa

query 参数重复解析为数组导致字符串操作挂掉

由于 query 支持数组,导致一旦 url 里重复的 query 参数如a=1&b=2&a=3这种形式,这导致 koa 的 ctx.query.a = [‘1’,‘3’],而写业务代码经常假设 ctx.query.a 为字符串,从而直接对齐进行字符串操作如 toUpperCase 等,这个操作直接会跑错,导致页面 500。

这种情况屡见不鲜,一来是客户端拼凑通参的时候一般不会去做去重处理,而前端又自以为然的认为是字符串,更坑爹的是 koa 自带的 types 的 query 是 any,导致即使用了 ts 也无济于事。

暂时没想到太好的办法,最多给 ctx.query 补充一下定义,明确告知可能是数组。重写 ctx.query 的风险较大,容易和其他中间件不兼容

sentry

mixed content 导致资源加载失败和 sentry 错误上报丢失

浏览器对于 mixed content 的限制越来越严重,之前只是在做活动页的时候,在电脑上测试一切正常,但是到了端内各种资源加载失败,原因是 facebook 等对 https 下加载 http 资源进行了严格的限制

到最近发现 chrome 已经有原来的只是提示 mixed content 的 warning,升级为直接拒绝加载资源了,这导致如果之前配置的 sentry 的 dsn 地址是 http 的,所有的错误上报都会丢失。 对于这种情况还算好处理,但是如果是加载的图片,问题就大得多了,如用户头像这些图片地址,因为来源众多,可能是用户上传,第三方抓取,运营配置等等等不同的渠道,很难在来源渠道上限制其地址必须是 http 的,所以需要我们在使用图片的地方都需要做转换处理,如果只是 web 端使用,最好的方式可能是使用无协议地址如将’http://www.baidu.com/logo.png’ 转换为’//www.baidu.com/logo.png’ ,但问题在于这些资源通常需要和客户端共享,而客户端可能不支持无协议头地址的加载,所以没办法只能是在渲染的地方进行转换,这实际上要求我们业务里最后不要直接使用 img 标签,而应该使用封装好的 Image 组件。 事实上一个成熟的 Image 组件包含了各种业务

  • 图片懒加载
  • 支持服务端渲染的响应式处理
  • 埋点上报
  • 图片加载失败的兜底效果
  • 图片加载失败的换源重试策略
  • webp 的兼容处理 所以以后业务里还是尽量避免使用裸的 img 标签吧。

react-router

hashHistory 不支持 push(url,state)

这导致 hybrid 页面里可能出问题

react-intl-universal

语言串用户的问题

由于 react-intl-universal 是通过闭包的 get 和 set 来实现用户语言的设置的,这在前端没啥问题,因为前端的用户环境是隔离的,但在服务端就存在问题了,服务端的用户共享全局变量,这导致 A 用户设置的语言可能会影响 B 用户。 解决方式

  1. 如果需要在服务端获取语言,需要将语言信息挂载到用户 context 下,而非全局变量里,可以通过中间件解决
import I18n from "i18n-2"
import { Context } from "koa"
export default (opt: any) => async (ctx: Context, next: any) => {
  ctx.intl = new I18n(opt) //为每个用户挂载一个独立的I18n实例
  ctx.intl.get = ctx.intl.__ //由于i18n-2不支持自定义get方法,且为了和前端的方法名保持一致,所以进行方法别名
  ctx.intl.setLocale(ctx.language) // 设置语言,暂时不考虑地区支持(有坑)
  await next()
}
  1. 如果要在服务端渲染的组件里获取语言,需要在 render 之前再一次进行 init 操作,确保 render 和 init 是原子操作,不会被其他用户污染,现在 react 的 renderToString 是同步的,这样做没啥问题,但一旦改成异步的就行不通了,封装一个操作将该两步操作封装起来
function renderMarkup(element, ctx) {
  intl.init({
    currentLocale: ctx.locale,
    locales: { [ctx.locale]: ctx.messages },
  }) // 服务端使用,这里不要使用await,保证 intl.init和ctx.render是原子操作
  return renderToString(element)
}

console.log alias

下述代码在某些机型下会出现问题,见 https://www.reddit.com/r/javascript/comments/3a9buu/javascript_uncaught_typeerror_illegal_invocation/

const log = console.log
log("xxx")

服务端环境判断问题

见https://github.com/alibaba/react-intl-universal/pull/81, 由于node层在某种情况下会将一些全局变量挂载到global.window等上面,这导致简单的通过`typeof window !== "undefined"`并不靠谱,采用react的判断机制更加靠谱些

export const canUseDOM: boolean = !!( typeof window !== ‘undefined’ && typeof window.document !== ‘undefined’ && typeof window.document.createElement !== ‘undefined’ );

当然这也可能被mock对象破坏掉,业务代码中通过webpack.DefinePlugin来实现编译时的判断更加靠谱,库中就只能增加更加严格的判断了

react-motion

卸载动画处理比较麻烦,可以考虑 react-spring,其卸载动画处理更加方便

nodejs

path 检查机制变动,导致 node 8 和 node 9+不兼容

https://github.com/nodejs/node/commit/b961d9fd83c963657c2305ed13ff447573eac852 node8 默认不会检查未 encode 的 url,但是 node9 之后会检查未 encode url,如果本地 node 版本和线上 node 版本不一致,则可能导致线下测试没问题,线上会挂掉

node8 对含有空格 url 处理存在 bug

https://xcoder.in/2017/12/13/node-http-parser-spaces-in-url/ ,如果 node 层前面有 nginx,这会导致非法爬虫触发 502 报警, 原因: node8 和 nginx 对空格 url 的处理都存在 bug,这导致含有空格的 url 请求会经过 nginx(按规范 nginx 应该直接返回 4xx 错误)到达 node 层处理,node 层返回 empty response,触发 nginx 502 错误。

npm script 的 glob 处理

Lark20190601-234311 npm scripts中的命令里的glob模式并不会直接交给shell做glob展开,而是应该作为字符串传递给第三方库,交给第三方库做glob解析,因此需要用单引号包住,防止元字符问题,图中tslint是正确的,eslint是错误的

jest

className 变动问题

使用 jest 测试 styled-components 的组件时,发现 className 会发生变动,使用 jest-styled-component 就可以解决这个问题,react-router 里 memory router 的 key 值也会发生变动通过制定 keyLengt=0 即可解决该问题 见 https://github.com/styled-components/jest-styled-components/issues/102#issuecomment-471037995

兼容性问题

ios

ios 在 input disabled 的状态下的颜色会带有opacity:0.4

这样修复

input:disabled{
    color:@disabledColor;
    opacity: 1;
    -webkit-text-fill-color: @disabledColor;
}

safari 9|10 里 button 使用 flex 的 justify-content 不生效 #20

https://github.com/philipwalton/flexbugs/issues/236 ,button 果然很坑爹

安全问题

cdn 劫持问题

流量劫持是常见的网络攻击手段,最常见的劫持手段包括

  • http 劫持:常见的在你的网站中插入一些广告脚本,较为常见
  • dns 劫持:修改 dns 解析结果,如将 www.google.com 解析到百度的 ip 地址,相比 http 较为少见(但有的无良的运营商还是会干的)
  • https 劫持:安装了第三方的根证书可能会导致 https 劫持。 http 劫持和 dns 劫持是常见的劫持手段,虽然 https 可以有效的解决 http 资源劫持和部分防范 dns 劫持(dns 劫持虽然劫持了返回结果,但是由于证书校验,一般浏览器会进行安全提示或者拒绝加载资源),但是 https 却并不能完全解决 cdn 的劫持问题。 https 仅能够验证 cdn 服务器的合法性,但并不能保证 cdn 提供静态资源是我们预取的资源,比如 cdn 服务器遭受攻击资源被篡改,或者在 cdn 回源被运营商劫持,这些 https 完全无能为力,因此我们除了需要验证用户到 cdn 服务之间请求的合法性,还需要验证资源的完整性。 验证资源完整性通常都可以通过文件指纹来验证,比如常见的下载软件的地方都会提供一个 MD5 签名,帮助用户验证下载的软件是否被篡改,同理我们也可以通过文件指纹来验证 cdn 资源的完整性。

前端工程化

sdk 开发

sdk 的动态加载

sdk 里需要使用 sentry 来进行错误监控,又不想把 sentry 打包进 sdk 里,因此需要动态加载 sentry,但是如果 sentry 加载太晚,会造成 sentry 加载之前已有的错误上报的丢失,因此需要将在 sentry 加载完之前的错误收集起来,等 sdk 加载完之后一起上报。 方案如果手动的写一个队列有点麻烦,恰好 promise 内部有一个队列,我们可以考虑使用 promise 的队列来做处理

util / sentry.js
const SentryPromise = import("@sentry/browser").then(Sentry => {
  // sentry sdk一旦返回,立即初始化
  Sentry.init({
    dsn: "你的dsn",
  })
  window.Sentry = Sentry
  return Sentry
})
export { SentryPromise }
app.js(async () => {
  const sentry = await SentrPromsie
  try {
    a
  } catch (err) {
    sentry.capctureException(e)
  }
})()

技术选项

根据https://github.com/sorrycc/f2e-decision-tree 的决策树,总结下自己比较偏好的前端技术栈

  • 框架:react
  • 语言:typescript
  • CSS:styled-components

  • 状态管理: Rematch

    • 还有一些 ts 的类型问题需要解决
  • 编译工具:cra + react-app-rewired + customize-cra
  • 测试工具: jest

    • react 单元测试和页面快照测试: enzyme + jsdom
    • e2e 测试: 暂时还没做过
  • 国际化: context 做简单实现,其他还没调研
  • 路由库: react-router
  • 异常监控: sentry + react-error-boundary
  • 开发流程工具:

    • 包管理工具: yarn
    • lint 工具: eslint (eslint-config-react-app)
    • 格式化: prettier
    • commit 规范: conventional-changelog
    • git 工作流: rebase + fastforward
  • 组件文档工具: react-styleguidist (storybook 的 start 更多一些,有空钻研一下)
  • 动画库: react-spring
  • 数据通讯: axios

    • mock 方案: 通过 dotenv 切换环境,连接 yapi 做数据 mock
  • 项目管理: monorepo
  • 文档站点工具:Docusaurus
  • 服务端渲染: razzle
  • 编写库的脚手架: 暂无
  • 编写项目的脚手架: 暂无

项目可参考 https://github.com/hardfist/hardfist_tools