我的技术和生活

  • GitChat 的问题

    GitChat 的问题

    目前来看,GitChat(gitbook.cn,简称 GC) 已经黄了。网站还在,但是已经基本没人运营。我认识的主创人员也都走了,所以把之前想吐没吐的槽吐一下吧。

    0. 起高楼

    我跟 GC 结缘较早。基本上,刚好在我投身知识付费领域的时候,GitChat 创办,然后我就加入做了一场:《第一场 GitChat 总结》。

    坦率地说,当时我对 GitChat 的印象不错,主要有三个原因:

    1. 采用众筹模式,创作者在开始创作之前就已经对用户需求、用户反馈有一些准备,分享更加有的放矢。尤其对于我这种选题偏门的人来说,很有参考价值。
    2. 文字内容有利于检索。相对于其它网站的视频课程,文字内容可以被更多人找到(如果网站主允许的话,GC 实际上不允许,搜索只能搜到简介)。
    3. 每场 Chat 的最后一个环节,GC 会把订阅者和作者拉到一个群里进行交流。我非常喜欢这个环节,可以交流很多内容,还能获得一个关系群。(可惜的是,交流完之后他们就会把群解散。)

    我觉得,GC 很好地填补了技术分享领域的一块空白:中篇收费文章,一次性说清楚一件事,不贪多:

    1. 比短文(例如我的博客)更系统、更完整、更有参考价值。很多时候你很难靠一篇博客完成一项任务,需要多看多试;而长文可以。拿来当参考书很好用。
    2. 比书本更聚焦,阅读压力更小,可以利用碎片时间,稳定的学会一项技能、了解一个知识点。
    3. 文字内容方便检索,可以覆盖更多人群,利用搜索引擎流量。

    主创人员很有想法,很熟悉行业,也很有执行力。GC 产品有不少值得称道的创新点,假以时日,凭借合适的市场补位,应该可以有所成就。

    1. 宴宾客

    接下来我在 GitChat 上创建了多次 Chat,还不断记录下一些 Chat idea,从我的博客可以看到:https://blog.meathill.com/?s=gitchat

    GC 也不断拓展运营方向。首先增设专栏,即加长版 Chat,长度最长可以接近书本。接下来引入年卡,首充打折,¥512/年,可以看所有 Chat(能看多少专栏我不太记得了)。对了,还有严选,即品质比较好的文章,可以有区别于普通文章的标记,相当于平台为作品背书,可以卖得更好。

    专栏的竞品不少,掘金也在做,而且投入的资源更多;传统图书虽然不挣钱,但是品级高,所以大家也都愿意做。年卡价格其实不贵,Chat 定价多半在 10~20之间,512 相当于能看 50 篇高质量的长文,即使暂时用不上,也可以先加到已购里,等需要的时候在看。

    后来,GC 出了一篇爆款,好像叫《Java 200 道面试题全解析》,卖出了 1w+。我感觉,很多变形操作就是从这篇爆款面世后出现的……

    2. 楼塌了

    在爆款之前,GC 存在的最大问题就是内容稂莠不齐。跟其它平台比起来,GC 是行业新人,缺少积累,所以作者门槛设得比较低。有些作者甚至对自己写的内容都搞不清楚;有些作者文笔很差,前言不搭后语;有些作者会在用户群里发盗版资源,拉人头报名自己的 Chat……

    GC 团队似乎没有什么特别好的办法,只好推出“严选”标签,帮好文章背书。但是 GC 是专业平台,编辑本身对技术不是很在行,所以严选标签的选择也一言难尽……

    这个问题随着爆款出现变得更加严重。爆款有成瘾性,不仅对作者,对平台更是如此。从《200+ Java 面试题》之后,普通作者的生存环境更加严酷。一方面,混杂其中的低端垃圾文拉低了整个平台的质量,付费阅读很少,大部分都是年卡会员打卡,分成很少。另一方面,平台不断把有限的资源投入到推广爆款文章和“看起来像”爆款文章的文章里面,一时间,“面试题”与“面试技巧”齐飞,“进大厂”共“快拿 offer”一色。普通作者(就是我)的文章更难出头,甚至连想加“严选”都要跟编辑反复沟通。

    而普通作者的微信群沟通更是直接被取消了。

    于是,我的创作欲望越来越低;别的作者也是如此,微信群也越来越沉寂。终于有一天,有个作者问:“GitChat 是不是黄了?”没人回答,可能真的是黄了吧。

    3. 总结

    在我看来,GitChat 初期的产品定位有独到之处,产品形态也做得不错。但是运营时遇到两个问题:

    1. 作者能力良莠不齐,作品质量参差不齐。为了降低读者购买的决策成本,加设年卡;结果又降低了作者的收益,进而降低了创作热情。
    2. (可能)由于资金压力和流量成本,片面追求爆款。导致平台上一时间充斥着各种无营养的面试文,损害了真正知识获取者的体验。

    当然,吐槽容易,解决问题很难。即使假设大家都现金购买,我一篇文章也不过几百块,完全覆盖不了我写文章的成本。所以,搭建一个作者愿意写,读者愿意花钱看的中篇平台,还有编辑负责内容审核和读者群搭建,这个商业模式是否真的成立,我也说不准。

    不过,我真的希望有个这样的长文平台,希望将来会有。

  • 重发老视频:使用 CSS 制作工序流程图

    整理之前录的视频,发现一个漏掉没有上传的:

    这个视频里,我演示了如何使用纯 HTML + CSS 制作工序流程图。涉及到的技术包括:

    1. display:flex Flex 布局
    2. 使用 order: N 调整显示顺序,以实现响应式
    3. 使用 position:XX 调整定位

    虽然项目不大,不过大部分布局相关的技术都有所涉及,很适合刚入门和初级同学学习。


    今年想继续在直播、视频方面发力,希望大家支持。如果有什么想听的想看的想学的,也欢迎点菜。

  • Tailwind.css + Postcss 笔记

    Tailwind.css + Postcss 笔记

    0. 缘由

    去年,一篇《Tailwind CSS: From Side-Project Byproduct to Multi-Million Dollar Business》在我的时间线上刷屏,作为 side project 和自由职业的翘楚,他的产品和商业项目十分令人羡慕。

    所以,我一直想找个机会试用一下 Tailwind.css。这次春节,想着放松休闲一下,就开了个小项目,尝试一下新技术栈:

    1. Vue3 全家桶
    2. Tailwind.css + PostCSS
    3. Webpack 工具链

    这篇笔记用来记录心得和体会。


    1. 基础

    官方网站:https://tailwindcss.com/

    2. 安装&配置

    npm install tailwindcss@latest postcss@latest autoprefixer@latest
    // postcss.config.js
    module.exports = {
       plugins: {
         tailwindcss: {},
         autoprefixer: {},
       }
    }

    2.1 创建 tailwindcss 配置

    npx tailwindcss init

    生成的配置文件如下:

    // tailwind.config.js
    module.exports = {
      purge: [],
      darkMode: false, // or 'media' or 'class'
      theme: {
        extend: {},
      },
      variants: {},
      plugins: [],
    }

    2.2 创建 CSS

    @tailwind base;
    @tailwind components;
    @tailwind utilities;

    这个 CSS 无法直接被浏览器使用,需要经过 PostCSS 调用 Tailwind 插件编译后才行。

    2.3 配置 Webpack

    只需要配置 CSS 和 Stylus 规则:

    module.exports = {
      module: {
        rules: [
          {
             test: /.css$/,
             use: [
               isDevServer ? 'style-loader' : MiniCssExtractPlugin.loader,
               'css-loader',
               'postcss-loader'
             ]
           },
           {
             test: /.styl(us)?$/,
             use: [
               isDevServer ? 'style-loader' : MiniCssExtractPlugin.loader,
               'css-loader',
               'postcss-loader',
               'stylus-loader',
             ],
           },
        ]
      }
    }

    2.4 配置 .browserslistrc

    PostCSS 同样需要用 browserslist 处理兼容性问题,所以一定要配置好,比如我近期喜欢用 bootstrap-icons 为图标,需要用到 svg-mask 系列属性,在 Chrome 里就需要补充前缀。那么,如果 browserslist 里没有 Chrome 就不会加前缀(我昨天就踩在这个坑里)。可以使用 npx browserslist 来检查。

    2.5 修改 npm scripts

    PostCSS 和 Tailwind.css 需要用 NODE_ENV 变量决定动作内容,所以必须加到 npm scripts 里。

    {
      "scripts": {
        "serve": "NODE_ENV=development webpack serve --config build/webpack.config.js",
        "build": "NODE_ENV=production webpack --config build/webpack.config.prod.js --mode=production",
        "lint": "eslint --fix --ext=.vue,.js ./"
      }
    }

    2.6 完成

    至此,基础 Tailwind.css + PostCSS + Webpack 配置完成,接下来就可以使用 CSS 实现界面了。

  • 好书推荐《重构(第二版)》

    好书推荐《重构(第二版)》

    前些天偶然看到,《重构》发了第二版,而且是以 JavaScript 作为范例语言编写的。于是我马上买了一本,翻了一遍,还是熟悉的好味道。在这里推荐给大家。

    点击这里购买 。年前京东半价,现在稍微贵一些,大家可以等一等,或者自己凑凑单。

    也可以扫码购买:

    0. 我的编程启蒙

    我的编程技能是自学的。最初就是看一些少儿科普图书,上面会教些计算机基础知识,和 Basic 编程。所以很长一段时间里,我都只会在 BASIC 开发环境下执行程序,而对如何让代码在任意环境下执行一无所知,这严重限制了我编程能力的覆盖范围。

    幸运的是,等到我考上大学,正值第一波互联网泡沫破灭,Web 开发风生水起,Flash 蒸蒸日上,所以,只要会写代码,就有地方能跑,而且是一个大平台。有赖于此,我的编程能力可以稳步提升。

    1. 《重构》(第一版)

    但一直自学,缺少交流和指引,也大大限制了我的编程能力的发展。站在当时的角度,我并不觉得有什么问题;但是现在,我回顾自己的职业生涯,觉得那其实是很大的危机。如果就这么抹黑自学,毕业后以非科班的身份去社招,多半会陷入很多转行同学一样的尴尬境地。

    幸运的是,我无意中买到《重构》这本书,并且看完了。这本书极大的开阔了我的视野,告诉我什么才是真正的编程、什么样的代码能事半功倍,以及除了完成业务之外,编程还有哪些工作。

    于是,我不仅在日后的开发工作中取得重大突破(幼儿园全 Flash 官网),在未来的招聘面试里也能够脱颖而出,顺利入行。

    以前的一篇博客里,我把《重构》列为对我职业生涯影响最大的书。

    2. 《重构(第二版)》

    相比于第一版,《重构(第二版)》又有两个比较大的改进:

    1. 产品类型升级,改成了大家更熟悉的电商订票网站
    2. 使用 JavaScript 重构代码,更适合现代前端程序员阅读

    对于广大靠前端开发维生,或者想靠前端开发维生的同学来说,这本书能极大的提升你的工作能力,让你未来的工作事半功倍。对于那些跟我一样靠自学、半路出家的非科班程序员,这本书就更加重要了,它能告诉你什么样的代码才是好代码,怎样写出更好的代码,以及该怎么写合适的代码。


    总之,推荐给大家。如果能使用我的 推广链接 就更好了。

  • 升级 Webpack 4 至 Webpack 5 笔记

    升级 Webpack 4 至 Webpack 5 笔记

    Webpack 5 已经发布一段时间了,我也找机会把几个项目从 Webpack 4 升级到 Webpack 5,从中积累了一些经验和教训,记录于本文。

    0. 请先阅读官方升级指引

    链接在此:To v5 from v4。(其实我也没认真读完……)

    0.1 @vue/cli 不要贸然升级

    @vue/cli 目前的版本号是 4.x,使用它创建的项目需要 webpack 4,升级到 Webpack 5 的话,因为两个版本配置文件存在差异,就无法正常使用了。所以如果是 @vue/cli 创建的项目,就不要贸然升级。

    如果你需要升级,比如想用最新的 TailwindCSS@3,那么可以直接升级 @vue/cli,目前最新版本是 v5.0.3,直接集成了 webpack@5。

    1. 升级依赖

    随着 Webpack 一起升级的,还有 webpack-cli、webpack-bundle-analyzer、webpack-dev-server;以及一众插件,比如 html-webpack-plugin、terser-webpack-plugin 等。

    因为存在依赖关系,建议大家一起升级:

    # 检查新版本
    npm outdated
    
    # 安装新版本的 webpack 套件
    npm i webpack@5 webpack-cli@4 webpack-dev-server@3

    2. 升级配置文件

    大部分配置可以直接继续使用。

    3. 升级 npm scripts

    Webpack 5 对内部模块的使用有所调整,所以我们需要调整一下 npm scripts。

    3.1 webpack-dev-server

    # 仍然需要安装 webpack-dev-server
    npm i webpack-dev-server -D
    
    # webpack 4
    webpack-dev-server --config build/webpack.config.dev.js
    
    # webpack 5
    webpack serve --config build/webpack.config.dev.js

    3.2 webpack-bundle-analyzer

    # 仍然需要安装 webpack-bundle-analyzer
    npm i webpack-bundle-analyzer -D
    
    # webpack 5
    webpack --analyze --config build/webpack.config.js
    
    # webpack 4,配置 build/webpack.config.js 实现

    4. 问题&解决

    4.1 解决:`Can’t resolve ‘http’ in ‘axios’

    在一个项目中,因为需要针对不同浏览器进行不同的适配,所以我们给 .browserslist 加入了环境配置:

    [modern]
    last 5 chrome versions
    last 3 firefox versions
    last 2 safari versions
     
    [withie]
    last 5 chrome versions
    last 3 firefox versions
    last 2 safari versions
    edge >= 18

    然后编译时就会报这个错误:

    Can't resolve 'http' in 'axios'

    经过一段时间的 Google,发现给 webpack.config.js 添加 target: 'web' 可解。所以我猜测,是因为我们的 .browserslist 有环境配置,所以 webpack 没认出来,所以当作 node 来打包。

    在 Webpack 4 时期,Webpack 自带针对 node.js 的 polyfill,所以没什么问题;但是 Webpack 5 把这个 polyfill 移除了,所以就报错。

    4.2 解决 HMR(自动更新,热模块更新,hot module reload)失效的问题

    使用 Webpack 5 后,有些项目的自动更新会失效,这是因为 Webpack 没有正确的识别项目的执行环境,错把它当成 node.js。这个时候,在 package.json 里添加 target: web' 即可解决。

  • 欢迎欢迎

    欢迎欢迎

    欢迎来到我的博客,我是 Meathill,想了解我可以点 关于我

    除了程序员,我还是个兴趣使然的 Web 开发视频制作者,欢迎在 B 站观看我的作品:https://space.bilibili.com/7409098。我的 YouTube 频道:肉山全栈小课堂 – YouTube

    近期作品

    • Awesome Comment 争做最好的评论系统,目标竞品 Disqus
    • 拜拜 基于 ChatGPT 开发的拜佛应用,很好玩,推荐大家试试
    • dailylift.io AI 帮你更懂自己
    • tutolang:用来生成优质编程教学视频的小语言,目前还在设计开发中
    • lazymelody.com:基于 Vite+Vue3+TailwindCSS 开发的乐谱网站
    • minesweeper:用 Vite+Vue3+TailwindCSS 复刻的扫雷游戏
    • mywordle.org:仿 wordle 猜字游戏

    欢迎留言点播各种内容。

    (更多…)
  • Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    Webpack 不支持 `import.meta`,利用 ESM 在浏览器里使用 yargs

    前些天遇到一个需求:解析 curl 请求,并转换成 ajax 请求由浏览器发出去。

    我觉得这个需求听起来不算稀罕,理论上应该有现成的库。于是在 npm 找了一下,发现 curlconverter 似乎可以满足需求。但是使用的时候报错:Module parse failed: Unexpected token

    这个错误很奇怪,看起来像是 loader 没配好。打开报错的文件位置,怎么看语法都没问题。尝试修改 webpack 配置,也未果。因为项目是 vue-cli 创建的,在如何查看最终配置上也浪费很多时间。

    最后继续诉诸 Google,关键词换来换去,终于在搜索 mjs Module parse failed: Unexpected token 时找到这个 issue:https://github.com/arnog/mathlive/issues/525,继而找到 https://github.com/webpack/webpack/issues/6719,终于确定,这是 webpack 的问题。

    因为 webpack 不支持 import.meta,所以会把 import.meta 当成语法错报告。我觉得这个行为很扯,因为 loader 配错也会报这个,所以对于第一次接触到这个问题的开发者(比如我)而言,会浪费大量时间在那些初级错误的搜索结果里。

    接下来解决问题。

    curlconverter 虽然不能直接使用,但仔细阅读它的代码,其中 https://github.com/NickCarneiro/curlconverter/blob/master/util.js 解析 curl 命令的功能实现应该问题不大,我只要想办法把 yargs 加载进来即可。而 yargs 支持浏览器 ESM 加载,所以我在页面里添加了 <script type="module" src="./yargs.js">,使用如下代码:

    // 虽然 yargs 已经到 16.2,但是 16.0.4 之后的版本都有问题
    import Yargs from 'https://unpkg.com/yargs@16.0.3/browser.mjs';
    
    // 加载完成 yargs 之后,把它挂载到 window 上
    window.Yargs = Yargs;

    将 yargs 挂到 window 上,成为外部库。然后在 vue.config.js 里配置 externals:

    module.exports = {
      chainWebpack: config => {
        config.externals.yargs = 'commonjs Yargs';
      }
    }

    接下来,将前面说的 utils.js 复制到本地并修改其中 parseCurlCommand 的实现,最终完成了需求。


    总结一下:

    1. 使用 yargs 解析命令行请求比较方便,远比自己写方便
    2. yargs 无法配合 webpack,据说可以配合 rollup 或者 snowpack,在我的 vue-cli 项目中,需要使用一些特殊的手段加载
    3. curlconverter 也很好用,可惜不能直接用
  • 我的第一段 OpenResty 代码

    我的第一段 OpenResty 代码

    前端同学需要一个上传文件的后端做一些调试,我刚好有,就给她用了。然后遇到跨域问题,一般来说我会在 PHP 里解决,不过这次我想挑战一下 nginx,于是找到这个答案。照做之后,OPTIONS 请求按照预期返回了正确的头,但是 POST 不行。

    于是我在群里请教同事,结果被老板批评:

    1. 在 nginx 里,“IF is evil”,因为要在一些特定条件下,它的行为会跟预期完全相反;有时候甚至会引起 segfault
    2. 我厂是 OpenResty,没理由不用 OpenResty

    行吧,本来就是为了跨出舒适区,不妨走远一点,OpenResty 就 OpenResty。

    0. 用 OpenResty 替换 Nginx

    我的系统是 Ubuntu 18.04,所以先按照 官方文档 安装最新版本的 OpenResty。

    安装完成之后,可以通过 service --status-all 查看服务列表,不出意外的话,能看到 openresty 服务。

    停掉 nginx,然后启动 openresty:

    service nginx stop
    service openresty start

    此时 openresty 的配置位于 /usr/local/openresty/nginx/conf/nginx.conf,默认的 web 服务地址是 /usr/local/openresty/nginx/html/,所以我们需要修改一下,以便继续使用 /var/www/html,也就是之前 nginx 的网页文件。

    1. 继续使用 nginx 的配置

    修改配置的方式很简单,OpenResty 本来也兼容 nginx 的配置。对于我而言,最简单的做法是干掉 server 的部分,然后加载 nginx 的网站配置,也就是这两句:

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;

    此处会有个小问题:PHP 网站的配置里需要加载 snippets/astcgi-php.conf,默认使用的是相对路径,需要改成绝对路径 /etc/nginx/snippets/fastcgi-php.conf

    至于其它配置,比如 gzipssl 等,酌情拷过来即可。

    2. 增加配置以自动输出请求头

    最后就是写 lua,这部分难度不大,基本就是普通的 lua 和 nginx lua API。写好的配置是这样的:

    location / {
      access_by_lua_block {
        if ngx.req.get_method() == 'POST' then
          ngx.header['Access-Control-Allow-Origin'] = '*';
          ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
          ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
          ngx.header['Access-Control-Allow-Expose-Headers'] = 'Content-Length,Content-Range';
     
        elseif ngx.req.get_method() == 'OPTIONS' then
          ngx.header['Access-Control-Allow-Origin'] = '*';
          ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
          ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
          ngx.header['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS';
          ngx.header['Access-Control-Allow-Headers'] = 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
          ngx.header['Access-Control-Max-Age'] = 1728000;
          ngx.header['Content-Type'] = 'text/plain; charset=utf-8';
          return 204;
        end
      }
      # First attempt to serve request as file, then
      # as directory, then fall back to displaying a 404.
      try_files $uri $uri/ /index.php$args;
    }

    首先,使用 access_by_lua_block 添加 lua 代码块。这里注意,一定要用 access_by_lua_block,不能用 content_by_lua_block,因为我们后面还要用 proxy_pass 把请求反向代理给 php-fpm 处理,而 content_by_lua_block 就直接返回了。

    然后,因为要添加 headers,所以要在能输出其它内容前先输出头。所以这个代码块要放在最前面,前面不能有其它输出。

    3. 总结

    需求不大,代码也比较简单,不过还是花了大约一天的时间调试。将来可能会写更多的 OpenResty lua,到时候再慢慢分享咯。

    另外我也搞不清楚为啥 nginx 这么流行的一个软件竟然有 if 这种问题。有知道的同学请指教。

  • 在任意上下文执行代码,`new Function` vs `eval()`

    在任意上下文执行代码,`new Function` vs `eval()`

    0. 需求及方向

    我厂的 Navigator 扩展遇到一个需求:

    1. 动态生成一段 JS 在浏览器里运行
    2. 能重复生成、重复运行
    3. 能生成一段新代码,继续在上一次的环境里运行

    其中(3)是新需求,(1)(2)已经比较完善地实现了。主要方案是使用 <script type="module">,这样加载的 JS 会自动执行,并且与全局环境相隔离,不会污染全局环境——这样就可以重复执行。

    (3)的难点在于,上一段 JS 已经执行过了,环境也自然释放了,我要么把上一阶段 JS 的执行结果保存下来,要么把执行环境保存下来。前者需要对 Navlang 编译器做大幅度的修改;后者则有很大的实施难度。

    经过研究和思考,我选择 保存环境 为攻关方向。即在上次执行结束前,在 window 上注册一个钩子函数,然后在后续追加执行的时候调用它,把后面的代码以函数的形式传进去,以便在上一次留下的环境里继续执行。

    1. 问题:访问环境里的变量

    添加钩子函数并不难,这样就行:

    // 在上一段代码最后添加钩子函数
    window.doNext = function (func) {
      func();
    }
    
    // 在新一段代码执行函数
    window.doNext(function () {
      // 新生成的函数
    });

    这样做虽然看起来是在上一次的上下文环境中执行新的函数,但由于 JS 的闭包和作用域链特性,实际执行时,新函数并不能访问到上一次上下文的数据。换言之,目的并没有实现。

    2. new Function() vs eval()

    然后回到标题。所以我们必须在上一次的环境里重新构建函数,这并不困难,其实开发浏览器扩展时,因为要协调四、五个运行环境,所以把函数序列化传输,再重新构建执行非常常见。于是我直接操起 new Function(),然后失败了。

    查阅 MDN,原来 new Function() 构造的函数,它的上下文会绑定在全局对象上(相当于 .call(null)),所以自然无法访问到上一次环境里的变量。

    再看 eval()文档,似乎可行,于是换用之,果然有效。至此,问题解决。

    3. 代码范例

    function navlangExecuteFunction(func) {
      // 使用模版字符串构建异步函数
      eval(`(async function doNext() {
        // 捕获中间可能出现的问题
        try {
          // 执行真正的函数
          await (${func})();
          // 为兼容 node.js,mock 一个 process 来处理执行结束
          process.exit();
        } catch (e) {
           if (!e.message.startsWith('Exit code:')) {
             console.error(e);
             process.exit(1);
           }
        }
      })()`);
    }
    window.navlangExecuteFunction = navlangExecuteFunction;
    const serializeFunction = (f) => {
      const serialized = f.toString();
      // Safari serializes async arrow functions with an invalid function keyword.
      // This needs to be removed in order for the function to be interpretable.
      const safariPrefix = 'async function ';
      if (serialized.startsWith(safariPrefix)) {
        const arrowIndex = serialized.indexOf('=>');
        const bracketIndex = serialized.indexOf('{');
        if (arrowIndex > -1 && (bracketIndex === -1 || arrowIndex < bracketIndex)) {
          return async ${serialized.slice(safariPrefix.length)};
        }
      }
      return serialized;
    };
    
    let nextStep = async function () {
      // 要执行的部分
    }
    nextStep = serializeFunction(nextStep);
    navlangExecuteFunction(nextStep);

    4. 可能带来的问题

    MDN 明确建议大家 不要用 eval。理由如下:

    1. 不安全。因为 eval() 会在当前环境执行代码,意味着攻击者可能窃取任何当前环境的数据。
    2. 性能差。现代 JS 引擎会对代码进行大量优化,包括转成机器码等。eval() 会破坏这个过程,使得运行性能大大降低。

    不过在我的场景下,这两个问题并不严重。一方面,被执行的追加代码都是由 Navlang 编译器生成,而不是任意第三方,它的安全性不会比其它的代码安全性低。另一方面,这个功能是帮助用户开发调试 Navlang 的,我们可以认为它大概率不会跑在性能敏感的环境里。

    另外,对我厂的 Navigator 产品而言,这样的方案还会让 GC 变得比较难执行。不过一样从 便利开发 的角度出发,我觉得性价比完全 OK。

    5. 总结

    这个需求比较特殊,涉及到 JS 函数的很多性质,包括运行时优化的知识,还是蛮值得大家琢磨的。

    另外就是所谓“尽信书不如无书”。eval() 的确存在一些问题,不能轻易使用,但是当需求摆在面前,经过充分的分析确认之后,我们该用还是要用。

  • 使用 Node.js 驱动 FFMPEG  录屏

    使用 Node.js 驱动 FFMPEG 录屏

    FFMPEG 功能非常强大,不仅能转换视频格式、压缩视频、添加字幕等,还能录制屏幕内容。使用 FFMPEG 录屏的方法很简单:

    # Linux
    ffmpeg -video_size 1024x768 -framerate 25 -f x11grab -i :0.0+100,200 output.mp4 
    
    # macOS
    ffmpeg -f avfoundation -list_devices true -i "" 
    
    # Windows
    ffmpeg -f gdigrab -framerate 30 -offset_x 10 -offset_y 20 -video_size 640x480 -show_region 1 -i desktop output.mkv 

    更详细的介绍可以参考官方文档,这里不做太多摘抄。

    使用 Node.js child_process 可以调用外部程序执行操作,详细的用法可以参考官方文档。大概来说,分为:

    1. exec/execFile 调用程序,当执行完成,一次性获得结果
    2. spawn 调用程序,过程中流式获得输出

    我的录屏对时间点有非常高的要求,力求准确。所以我只能选择 spawn,然后通过检查日志输出得到准确的录制开始时间。

    所以,我的代码大概如此:

    class FfmpegRecorder {
      // 其它函数略去
      startRecording(args) {
        this.isFileReady = new Promise((resolve, reject) => {
          this._isFileReadyResolve = resolve;
          this._isFileReadyReject = reject;
        });
        this.recording = spawn(this.ffmpeg, args);
        
        this.recording.stdout.on('data', this.onStdData);
        this.recording.stderr.on('data', this.onStdErr);
      }
      stopRecording() {
        this.recording.kill('SIGINT');
        const path = resolve(process.cwd(), 'a.mp4');
        return path;
      }
    
      onStdData(data) {
        data = data.toString();
        this.stdout += data;
        if (this.stdout.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
      onStdErr(data) {
        data = data.toString();
        this.stderr += data;
        if (this.stderr.indexOf('Output #0, mp4, to \'a.mp4\'') !== -1) {
          this._isFileReadyResolve();
          this._isFileReadyReject = null;
        }
      }
    }

    根据我在命令行里直接跑 ffmpeg 的结果,它会先初始化,然后开始录屏。录屏开始时,会输出 Output #0, mp4, to xxx.mp4 这样的日志,所以我就会反复检查 stdoutstderr,直到关键日志出现,然后告诉外面的程序开始录制了。

    这里比较奇怪的是,日志输出应该是正常的,走 stdout 通道,结果只能从 stderr 通道获取。我为防万一,两边都留下了同样的代码。可能我对 Linux 理解不够,将来再研究一下为什么会这样吧。

    上面的代码忽略了 onErroronExit 部分,有兴趣的同学请等我开源(公司代码)。

    在 Linux,stopRecording(),即 kill('SIGINT')(相当于按下 Ctrl+C)之后,FFMPEG 会终止录屏,并且生成可以播放的视频文件。但是在 Windows,只会留下一个无法播放的文件。

    通过观察命令行直接运行 ffmpeg 的结果和 node.js 保存的结果,我发现缺失了结束录制后处理视频文件的部分。实际上 FFMPEG 录屏时只会记录视频内容,录制结束后才会生成影片的meta 信息,而播放器必须读取后者才可以正常播放。所以我猜测在 Windows 下 kill('SIGINT') 会直接彻底杀死进程,而不仅仅是发送一个信号给 FFMPEG,并让它完成后续的工作。

    做出判断后,我尝试用按下 q 的方式通知 FFMPEG 停止工作,并等待 5s。果然成功,于是我构建了新类,继承前面的 FfmpegRecorder,大概代码如下:

    const endTag = /kb\/s:\d+.\d{2}/m;
    
    class WindowsRecorder extend FfmpegRecorder {
      stopRecording() {
        // 向 child process 输入 `q`
        this.recording.stdin.setEncoding('utf8');
        this.recording.stdin.write('q');
        const p = new Promise((resolve, reject) => {
          this._stopResolve = resolve;
          this._stopReject = reject;
        });
        // 设置一个超时保护 15s
        setTimeout(() => {
          this._stopReject();
        }, 15E3);
        return p;
      }
      onStdData(data) {
        super.onStdData(data);
        if (this._stopResolve && endTag.test(this.stdout)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
      onStdErr(data) {
        super.onStdErr(data);
        if (this._stopResolve && endTag.test(this.stderr)) {
          const path = resolve(process.cwd(), 'a.mp4');
          this._stopResolve(path);
        }
      }
    }

    为了功能完善,我没有选择等待固定的时间,而是继续检查日志,直到发现 endTag 标记。另外,我也留下了 15s 超时保护,避免某些暂时没遇到的问题破坏功能。

    至此,功能基本稳定完成。