<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://aiyou.life</id>
    <title>文享日志</title>
    <updated>2024-01-20T16:01:13.220Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://aiyou.life"/>
    <link rel="self" href="https://aiyou.life/atom.xml"/>
    <logo>https://aiyou.life/images/avatar.png</logo>
    <icon>https://aiyou.life/favicon.ico</icon>
    <rights>All rights reserved 2024, 文享日志</rights>
    <entry>
        <title type="html"><![CDATA[近年感慨一下]]></title>
        <id>https://aiyou.life/post/7w-Gt8VOo/</id>
        <link href="https://aiyou.life/post/7w-Gt8VOo/">
        </link>
        <updated>2023-08-13T14:26:12.000Z</updated>
        <summary type="html"><![CDATA[<p>周五下班回家，正晕晕乎乎的在路上走着，迎面而来的一个骑电动车的小姐姐突然把车停在路中间，然后放声大哭了起来。虽然跟我没啥关系，但心里多少有些波澜，感慨人间疾苦和自己的小幸运。</p>
]]></summary>
        <content type="html"><![CDATA[<p>周五下班回家，正晕晕乎乎的在路上走着，迎面而来的一个骑电动车的小姐姐突然把车停在路中间，然后放声大哭了起来。虽然跟我没啥关系，但心里多少有些波澜，感慨人间疾苦和自己的小幸运。</p>
<!-- more -->
<h2 id="写在之前">写在之前</h2>
<p>  再打开博客，发现上次更新已经是两年前了，最近两年上班感觉非常累，下班回家就想躺尸，没啥动力更新博客，逛逛 Github 啥的了。<br>
本来下面的内容是想写在 weibo 的，但内容有点多，还不如放在我自己的博客上。</p>
<h2 id="前言">前言</h2>
<p>  周五下班回家，正晕晕乎乎的在路上走着，迎面而来的一个骑电动车的小姐姐突然把车停在路中间，然后放声大哭了起来。虽然跟我没啥关系，但心里多少有些波澜，感慨人间疾苦。想着过往前 20 多年，我还是非常幸运的。昨天外出骑车🚴🏻，热辣的阳光，闷热的天气又把我搞得晕晕乎乎的，我又想起了我很幸运，很多人生的关键时刻，都踩了狗屎运的选对了。</p>
<h2 id="专业与行业">专业与行业</h2>
<p>  一直对计算机行业感兴趣，高中的时候，最喜欢马云和阿里。高考第一年所有志愿都选的计算机，且都不服从调剂，不出意外的落榜了。<br>
  第二年同上，最后侥幸去了石河子大学学了计算机专业。大学几年，庆幸自己一直有清晰的目标，知道自己想要什么，且为之付出行动。<br>
  最后大学毕业，成功进入互联网行业， 刚好赶上了行业的末班车。</p>
<h2 id="学业与事业">学业与事业</h2>
<p>  一直对学习和考试不太上道，所以没想着本科毕业去考研什么的。大学的课程，很多都是在及格线低分飘过。也庆幸大学没有浪费时间在乱七八糟的事情上，自学了很多东西。<br>
  实习的时候，也很幸运。同学拉我去面上海汉得，当时我一直心心念念的想去大厂，所以没啥兴趣，但还是去看了看，简历都没准备的情况下，就通过了。后来，海投各个大厂的简历都石城大海，上海汉得是剩下 offer 里最能看的一个，于是顺理成章的在汉得实习了 5 个月，还攒了 1w 块钱 !!!<br>
  毕业在汉得干了两个月转正了，但感觉继续呆在这里没啥前途，于是就准备跳槽了，非常非常幸运的，在 CNode 上看到了志毅发的招聘帖子，也非常幸运的在投简历，加微信后，在两个多星期都没消息的情况下，我能主动再多嘴发消息问一问情况，老板终于抽出时间来面我了，然后就通过了，于是从北京去了面朝大海，春暖花开的珠海。珠海真的好美啊，那个时候，总做梦梦到在彩色的天空下，我睡在悬崖边，而远处是一望无际蓝色的大海（有点像狮子王的那个经典的画面）。珠海头一年的工作真的非常非常非常辛苦，经常加班到很晚很晚，那段时间算是痛并快乐着吧。老板画饼的技术是一流的，我也真心激情澎湃，想一起搞出一番事业来，但由于剧本杀这个赛道，比拼的人力物力和各种资源，市场上有几个玩家搞得特别出色，我们创新推出的功能，非常 esay 的就被 copy 里过去，真的很难与人家竞争。后来团队准备独立成立公司，融资什么的，志毅也被老板暗戳戳的排挤走了，我成了团队里的技术 TOP，没啥值得欣喜的，只是感觉没有了成长空间，于是也准备跳槽走了。<br>
  面了心念念的 QQ，二面匆匆面了 5 分钟就完了，收到了通过的消息。真的非常奇怪，搞不懂什么情况，在把我凉了一个星期后，打过去电话问了问，说前一个人同意了 offer，现在不缺人了。也庆幸他没要我（半年后发生了大裁员）， 然后幸运的来到了目前这里，虽然日常心累，但也工资翻倍。</p>
<h2 id="同学与同事">同学与同事</h2>
<p>  男生没那么多弯弯绕，从学生时代到现在，遇到的很多人都非常好，我多少感觉自己有点冷酷，不太爱搭理人(想改改不动[允悲])，感觉干啥都无趣，也很感激大家能够包容。遇到的主管，合作伙伴什么的对我都非常好。</p>
<h2 id="诗与远方">诗与远方</h2>
<p>  一直想浪迹天涯，每个地方体验一下各种各样生活。也算是很幸运的去过了很多地方，也在几个城市体验过了生活 (北京上海深圳珠海杭州)，如果有机会，还想去成都长沙广州...<br>
  挺遗憾的，在深圳和珠海的时候，没有去附近的香港澳门东莞佛山等去转转。几个城市体验下来，还是珠海环境最好，但房价你妹的跟杭州都有的一拼。。<br>
  之前老姐和志毅一直推荐我投身 web3，出于没有大厂背景和资历，对往后职业规划不太好，先在大厂混几年资历，然后再看看情况。真的好羡慕志毅能远程工作，天天陪在老婆孩子旁边，还能年薪近百万啊!!!!!!!!!!!!</p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[账本开发小记]]></title>
        <id>https://aiyou.life/post/WTBK_yMNR/</id>
        <link href="https://aiyou.life/post/WTBK_yMNR/">
        </link>
        <updated>2021-12-11T13:32:37.000Z</updated>
        <summary type="html"><![CDATA[<p>上篇文章的日程规划中有提到我要开发个账本的产品，本来打算开发完了再整理文章的，但意识到到时候很可能很多东西都忘记了，所以趁有空，赶紧先记录一些。。</p>
]]></summary>
        <content type="html"><![CDATA[<p>上篇文章的日程规划中有提到我要开发个账本的产品，本来打算开发完了再整理文章的，但意识到到时候很可能很多东西都忘记了，所以趁有空，赶紧先记录一些。。</p>
<!-- more -->
<h2 id="前言">前言</h2>
<p>首先做账本这个东西，不仅仅是做账本。我希望通过做这个产品，能够串起来整个研发流程，了解每一个之前不熟悉的领域，能给自己带来技术层面的收获，在这个基础上，如果能够把账本这个软件做出来，解决我记账的问题的同时，也能够给别人带来便利，这样是最好的。</p>
<h2 id="规划">规划</h2>
<h3 id="产品形态">产品形态</h3>
<p>一款软件产品无外乎有 app，web 网页，小程序三种形态。</p>
<p>考虑到记账的这件事，对于大部分目标用户来说，都是在 app 或者 小程序中完成的。而让用户在手机中安装 app 成本很高，且目前也没有服务器资源，所以先使用免费的微信云服务，把小程序做起来。</p>
<h3 id="ui">UI</h3>
<p>之前花了好几个夜晚，在网上找免费的 sketch 或者 figma 模板，结果都不太满意。后来发现某款记账 App UI 挺好的，我打算先抄一下，在学习使用 figma 画他的 UI 图的时候，发现实在太费劲了。所以目前 UI 就先不考虑，先把整个功能实现了，再打磨 UI</p>
<h3 id="技术选型">技术选型</h3>
<p>Web 网页和小程序是前端技术栈无疑，只是 Vue 和 React 选哪个的问题。由于不太喜欢 Vue 模板，所以选择对 TypeScript 支持良好的 React。</p>
<p>小程序选择基于 React 技术栈的 Taro 框架，框架用的人多，遇到一些坑相对会少一些。</p>
<p>App 首选肯定 React Native，但一直用前端技术栈做这些没啥意思，到时候也可能尝试使用 flutter。</p>
<h3 id="架构设计">架构设计</h3>
<p>账本是一个比较注重隐私的产品，所以应该具备“本地模式”和“网络模式”两种形态。</p>
<p>此外，开发时要注意架构设计，可以便捷的切换本地模式和网络模式，在没有服务器的情况下，应用也可以正常使用。</p>
<p>开发可能涉及多包依赖，使用 lerna 来解决问题</p>
<p>考虑代码的多仓库管理，使用 git sub-module ，配合 lerna 进行开发</p>
<h3 id="自动化部署">自动化部署</h3>
<p>从学习和提高效率，规范代码，自动化构建、部署等角度来讲，需要部署一套 gitlab。</p>
<p>恰巧，吃灰许久的树莓派可以排上了用场。希望能他能担此重任。</p>
<h2 id="环境搭建">环境搭建</h2>
<p>首先确保网络是畅通的翻过去的环境，否则很多依赖装不上，坑很多。</p>
<h3 id="gitlab-安装">Gitlab 安装</h3>
<p>Gitlab 打算装载树莓派上，我的树莓派型号是 3B+，有 1G 的内存， 16G 的磁盘空间，装的 Ubuntu 系统。</p>
<p>Gitlab 在安装过程中坑点较多，安装时使用默认官方源，进行 <code>apt-get install</code> 安装，安装后软件后，根本运行不起来，后来查阅文档，发现只推荐在树莓派 4 的 4G 内存版本以上进行安装。淘宝搜了一下，因为穷，只能考虑安装到我的 MacBook Pro 开发机了。</p>
<p>为了不污染环境，安装了 docker，在 docker 中部署 gitlab，教程在官网是有的，根据<a href="https://docs.gitlab.com/ee/install/docker.html">说明</a>安装即可。</p>
<p>有几点需要注意的是：</p>
<ol>
<li>网站上写的默认给 gitlab docker 对外暴露的端口是 80、443 和 22。这几个端口在 MacOS 下是有限制的，建议都换成其他端口。</li>
<li>默认设置了 <code>hostname</code> 为 <code>gitlab.example.com</code>，这需要你更改本机的 hosts 文件，进行配置。</li>
<li>我在实践时 docker 给了 4G 的内存，4 个 CPU 核，gitlab 安装是失败的。给到了 8G 内存，才安装成功。</li>
</ol>
<h3 id="gitlab-runner-安装">Gitlab-Runner 安装</h3>
<p>Gitlab-Runner 是用来对仓库代码跑 CI/CD 等流程的工具。Runner 可以在本地部署，也可以直接用远程的，Gitlab 官网好像是有免费提供一个 Runner 进行使用，这个对标的是 Github Action。</p>
<p>考虑到不让我的树莓派吃灰，我决定把 Runner 部署到树莓派中。</p>
<p>远程连接到树莓派上，按照官网教程，也顺利的装上了。在测试功能的过程中发现项目代码打包不了，在安装依赖的过程中就卡死了。最后重装系统，重装 Runner 问题依旧，换成了树莓派原系统也不太行。</p>
<p>后来才想到可能是内存卡的问题，安装依赖时，<code>node_modules</code> 里成百上千个小文件，很考验内存卡性能，换了内存卡后，问题成功解决了。</p>
<p>还有就是在 Runner 中注册后，Gitlab 网页上发现 Runner 是不可用状态，此时需要手动执行命令</p>
<pre><code class="language-bash">sudo gitlab-runner verify
sudo gitlab-runner restart
</code></pre>
<p>遇到最无语的事情是在成功跑了几次任务后，给 Runner 改了标签后，YML 中 也把标签改了后，再运行任务时，显示的一直是挂起状态。把标签恢复了也不行，花了几个晚上尝试，没有结果。。。有心想看看源代码，找找 bug，奈何没这个实力和水平。。最后重装系统和软件才搞定，然后发现该标签后也能正常运行了....</p>
<p>其他一些坑点可以参照<a href="https://zhuanlan.zhihu.com/p/184936276">这篇文章</a></p>
<h3 id="yml-脚本编写">YML 脚本编写</h3>
<p>Runner 是根据 YML 脚本进行执行的。</p>
<p>一个前端项目的 YML 文件中一般包含了四个 Job: 依赖安装、Eslint 扫描、打包构建和部署。</p>
<figure data-type="image" tabindex="1"><img src="https://aiyou.life/post-images/1639239708604.png" alt="" loading="lazy"></figure>
<p>配置完四个 Job 后，发现部署巨慢无比，且在 build 阶段还总失败报错找不到依赖。尝试了很久解决不了。</p>
<p>观察发现每个 Job 的执行，都会重新初始化一遍项目，拉代码下来运行，<code>node_modules</code> 在配置完 cache 字段后，只需要安装一次。</p>
<p>后来想，是我自己一个人开发的话，没必要搞这么多步骤，最重要的是把部署这一步搞定就 ok..</p>
<h2 id="开发">开发</h2>
<p>整个项目难度不大，为了学习，我还是在项目中的多用自己写的一些工具: 比如说<a href="https://github.com/xdoer/PreQuest">请求库</a>，<a href="https://github.com/xdoer/StateBus">状态管理</a>，<a href="https://github.com/xdoer/ScriptRunner">脚本管理</a>，<a href="https://github.com/xdoer/TaroRouter">Taro 路由管理</a>，<a href="https://github.com/xdoer/Mock">Mock 工具</a>，<a href="https://github.com/xdoer/chokidar">文件监听</a>等等。</p>
<h3 id="123-日">1.23 日</h3>
<p>目前进度是:</p>
<ol>
<li>基本的界面 UI 完成初版。</li>
<li>接口 Mock 工具完成了初版</li>
<li>脚本管理工具支持子进程的运行模式</li>
<li>状态管理工具完善</li>
<li>实现了更好用的弹窗管理</li>
<li>实现了前端版本的接口路由，可自由切换 WxCloud、Storage、和 Server 的数据存储模式</li>
</ol>
<p>UI 一览:<br>
<img src="https://aiyou.life/post-images/1642932965402.png" alt="" loading="lazy"><br>
<img src="https://aiyou.life/post-images/1642933228435.png" alt="" loading="lazy"><br>
<img src="https://aiyou.life/post-images/1642933236561.png" alt="" loading="lazy"><br>
<img src="https://aiyou.life/post-images/1642932991041.png" alt="" loading="lazy"></p>
<p><strong>总结</strong><br>
虽然总体进度很慢，但在技术层面也算是小有收获。</p>
<p>账本的目标是要实现将数据自由存到服务器(Server)、微信云服务器(WxCloud)、和本地(Local)。怎么能很好的兼容这三者？WxCloud 和 Local 都是前端代码可以直接调用的，意思是前端就可以直接访问数据库进行操作，而 Sever 模式需要走常规的接口请求。</p>
<p>为了方便上层的开发，即开发时不需要感知到底运行的是那种模式，考虑将 WxCloud 和 Local 模式的调用方式和 Server 保持一致。这是是什么意思呢？</p>
<pre><code class="language-ts">const fetchData = (options) =&gt; {
    // wxCloud 模式
    if(Env.isWxCloud) return cloudServer.get(options)

    // storage 模式
    if(Env.isLocal) return storageServer.get(options)

    // erver 模式
    return fetch(path, options)
}

export default function App() {
    const [user, setUser] = useState()

    useEffect(() =&gt; {
        // 渲染 UI 时，不需要管底层到底怎么取的数据
        fetchData('/user', { id: 1 }).then(res =&gt; setUser(res)
    }, [])

    return &lt;div&gt;&lt;/div&gt;
}
</code></pre>
<p>这就需要在前端实现类似 koa-router 之类的库，来支撑 WxCloud 和 Local 模式。动手实践后，才发现这个东西非常简单。其实就是一个 key 和 callback 的映射。</p>
<pre><code class="language-ts">class Router {
  routers: Route = {}

  use(path, cb) {
    this.routers[path] = cb
    return this
  }

  call(options) {
    const { path, ...opts } = options
    return this.routers[path](opts)
  }

  merge(routers) {
    this.routers = { ...this.routers, ...routers }
    return this
  }
}
</code></pre>
<p>用法如下:</p>
<pre><code class="language-ts">const appRouter = new Router()
const userRouter = new Router()

userRouter.use('/user', (options) =&gt; {
    return userService.getUser(options)
})

appRouter.merge(userRouter.routers)
</code></pre>
<p>有了前端版本的路由，上面的 fetchData 就可以改造为下面这样，从 wxCloud 数据库中取数据还是 Storage 中取数据，交由 userService 去取处理。</p>
<pre><code class="language-ts">function fetchData(options) {
    if(Env.isServer) return  fetch(options)
    return appRouter.call(options)
}
</code></pre>
<p>而在 WxCloud 和 Storage 之间，为了取数据方式的一致性，简单的封装了增删查改。但是对于连表查询等高级能力，目前还没搞定。之前还研究了一下 sqlite.js，尝试在小程序的 wasm 中运行一下，折腾了很久，也搞定不定。。</p>
<p>除了前端的接口路由外，还想分享的一点是实现了脚本管理工具的部分脚本的子进程运行模式，为什么要搞这个呢？<br>
我们都知道，当我们跑起一个脚本时，一般逻辑都是跑在主进程上的，我实现的脚本管理工具也是一样，它会加载配置的各个模块，自动填充配置的参数运行。本质也是运行在主线程上。</p>
<p>mock 工具原理本质就是开启了一个 Http Server，我设计的每个 mock 文件，都包含了一个 path 和一些 response，这也就是说我的 Http Server 中的路由是动态修改的。</p>
<p>mock 工具简易实现如下:</p>
<pre><code class="language-ts">function mockServer() {
    const app = express()

    fs.readdirSync('./mock').forEach(file =&gt; {
        const { path, response } = require(file)
        app.use(path, (, res) =&gt; {
            res.json(response)
        })
    }

    app.listen(3000)
}

// mock/user.ts
export default {
    path: 'user',
    response: {
        name: '张三'
    }
}
</code></pre>
<p>运行起上面的代码后，将会在 mock 文件夹下找到 user.ts，加载并应用。实际开发过程中，我们可以需要经常修改 user.ts 文件，但修改后需要在下次启动项目才会生效。此时就要 <code>ctrl c</code> 结束脚本运行，然后再 <code>yarn dev</code> 开启脚本。这样繁琐且麻烦，有没有办法解决这个问题？child_process</p>
<p>child_process 可以在主线程中开启一个子线程去运行代码，实现起来也很简单，但有个大坑就是子线程创建的子线程，在关闭主线程后，孙子线程还会继续保留。</p>
<p>我在脚本管理器中设计了一个 subProcess 的参数，标志要开启子线程运行代码。当监听到 mock 文件夹下的 user.ts 文件变动，则再次运行 mock Server 的代码，此时如果发现 mock Server 已经有运行在一个子线程上，则会关了它，再开启一个。。</p>
<p>原理很简单，但这足足花了我两天时间。。之前实现的逻辑生成了多个子线程和孙子线程，关闭 mock server 的线程时，都不知道这个线程运行在子线程还是孙子线程上。。。说多了都是泪。。。</p>
<p>最终的效果非常不错:<br>
可以看到，在我保存完文件后，mock server 自动重启，运行 curl 命名，修改生效。<br>
<img src="https://aiyou.life/post-images/1642938745385.gif" alt="" loading="lazy"></p>
<p>核心代码:</p>
<pre><code class="language-ts"> childprocess.spawn(
     'ts-node',
      [
          '-e', 
          `require(&quot;${module}&quot;)(...${JSON.stringify(args)})`, 
          '--skip-project'
      ], 
      {
        stdio: ['inherit', 'inherit', 'inherit']
      }
)
</code></pre>
<h3 id="23-日">2.3 日</h3>
<p>目前进度:</p>
<ol>
<li>代码仓库整到了 Github，使用 Github Action 进行自动化构建</li>
<li>写了部分系统设计文档。设计了部分数据表和本地模式下的接口实现</li>
<li>重构代码，用状态管理工具去控制运行模式（Local、WxCloud、Server）</li>
<li>状态管理的 Storage 模式</li>
<li>本地模式下的 DB 数据初始化</li>
<li>本地模式初步跑通</li>
</ol>
<p>这里本地模式是指 Local 和 WxCloud，能在前端直接操作数据库</p>
<p>UI 一览:<br>
<img src="https://aiyou.life/post-images/1643887159019.png" alt="" loading="lazy"></p>
<p><strong>总结</strong><br>
首先把代码仓库切到 Github 了，电脑装的 docker 和 gitlab，运行起来资源占用太大，电脑风扇总是飞转，推完代码后，树莓派的 runner 也是跑的很慢，要几分钟才能跑完 CI，然后上传代码后预览。在一次 docker 升级后，docker gui 彻底打不开，索性直接卸载，转移仓库到 github 上了，花了一点时间研究了一下 Github Action 写了一下脚本，实现了向 delpoy 代码分支 push 代码，自动打包预览的功能。打包时间比树莓派快多了。。</p>
<p>然后就是写系统设计文档了，这玩意不写的话，没办法进行下去。脑子里都是一些琐碎的想法，不整理出来，无从下手写代码。设计了主要的数据表模型，存什么字段都大致罗列了一下；同时也写了一下实现了接口规范，响应的数据，传的参数等。</p>
<p>关于运行模式（Local、WxCloud、Server），之前没考虑好，是打算在打包的时候，根据环境变量来控制运行哪种模式，最近发现不能这么搞，应该放到前端，有个开关来控制数据存到哪里。切换开关后，状态应当是一直生效的，所以还要把状态管理和持久化数据进行结合，同样考虑到跨平台的原因，存取持久化数据可能是一个异步的过程，所以整个重构还是有一点挑战的。同时也是考虑目前没有服务器资源，所以重心放在了先将本地模式跑通。</p>
<p>本地模式下的数据初始化。UI 里面有一部分数据应当初始化到 DB 中，比如账单的支出类型、币种类型、资产类型等数据。所以在什么时机初始化这些数据？怎么保证发起的请求是在初始化 DB 后？怎么保证只初始化一遍数据（即下次打开应用不再初始化数据）？这些都是我在设计时遇到的挑战。最终解决方式是在调用接口时，用到了某张表，先查看索引表有没有这张表的记录，有的话，说明初始化了，没有的话，则进行初始化操作。这个流程相对符合直觉，但是要考虑的还要更多一些，比如并发请求，怎么控制只初始化一次等等。。解决方法还是之前在 axios 中看到的异步控制 Promise 的方案，简直帮了大忙。</p>
<pre><code class="language-ts">let promiseReslove
let promise = new Promise((resolve) =&gt; promiseReslove = resolve)
</code></pre>
<p>解决完上述问题，整个本地模式初步跑通了，效果图见上边的 UI 一览。整个效果还是令人满意的。<br>
昨天在调试 wxCloud 模式的时候，突然小程序 IDE 报了个错，这才发现免费的微信云开发数据库，每天只有 500 次的读写操作。然后看了一下文档，数据库同时连接数只有 5 个，这就说明数据库的读写等操作，只能放到云函数里去弄，前端改造成本较低，代码设计之初就添加了 DB 的 Connect 层，后续添加或者调整存储形式，都比较容易。</p>
<h3 id="312-日">3.12 日</h3>
<p>目前进度:</p>
<ol>
<li>重构了浮层管理</li>
<li>重构了 prequest 的类型系统</li>
<li>重构了 <a href="https://github.com/xdoer/PreQuest/tree/main/packages/use-request">use-request</a></li>
<li>完善了<a href="https://github.com/xdoer/StateBus">状态管理</a></li>
<li>完成了本地模式的基本功能，提交审核了第一个版本</li>
</ol>
<p><strong>总结</strong><br>
通过写这个小应用，发现之前写的小工具的很多不足，比如请求库需要手动的注入类型、状态管理初始化不够好用等等，花了比较长的时间来完善这些工具。</p>
<p>然后就是今天终于提审了第一个版本，基本功能是有了。但对于 WxCloud 和 Server 模式，不打算继续搞了。整个小程序剩下的工作，就是写业务逻辑方面的内容，基础的重复性工作，增删查改调 UI 什么的，感觉没啥意思。</p>
<p>最后小程序名称是: 我记个账，欢迎体验</p>
<h2 id="后记">后记</h2>
<p>这篇文章从去年 12 月 11号开始写起，迭代了好几个版本，内容也挺多了，就不继续更新了。期间，利用业余时间也搞了很多事情。总的来说，小有收获。对于这个账本的开发，目前会告一段落。</p>
<p>最近有个想法，想搞一个网站或者Chrome插件什么的，可以抓取各种信息展示给用户，类似早期的今日头条。。但是专业性更强，类似 daily.dev 这个插件。<br>
<img src="https://aiyou.life/post-images/1647085831769.png" alt="" loading="lazy"></p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[让生活井井有条]]></title>
        <id>https://aiyou.life/post/7Ta0APQTd/</id>
        <link href="https://aiyou.life/post/7Ta0APQTd/">
        </link>
        <updated>2021-11-27T13:22:34.000Z</updated>
        <summary type="html"><![CDATA[<p>想来好像蛮久没有更新博客了，想写些东西，但是还没有明确的思路写什么，那么就大致记录一下近况，和一些所见所想所感悟。。如此，本文主旨就是标题这个样子了...</p>
]]></summary>
        <content type="html"><![CDATA[<p>想来好像蛮久没有更新博客了，想写些东西，但是还没有明确的思路写什么，那么就大致记录一下近况，和一些所见所想所感悟。。如此，本文主旨就是标题这个样子了...</p>
<!-- more -->
<h2 id="职业">职业</h2>
<p>  犹犹豫豫，键盘打了半天字，删了更改改了删，想想还是该从刚毕业前夕写起。恍恍惚惚，大学毕业都两年多了。。。。。。。。。</p>
<p>  应该是 17 年 12 月的某一天，准备去上晚上 8:00-10:00 的选修课时，白世伟喊我一起去到上海汉得在我们学校的宣讲会看看。本着涨涨见识的目的，旷了选修课，和他一起去了，印象中不记得讲了些什么，只记得每人发了一张纸，写了一些智力题，就草草结束了。</p>
<p>  后来汉得在学校组织面试，我把大学做的<a href="https://hanblog.herokuapp.com/idx/0">博客</a>给面试官演示了一遍，问了几个问题，顺利通过了面试，拿到了汉得的实习 offer。本来是对这家公司无感的，但是来学校宣讲的公司，感觉一个比一个拉胯，我开始在网上投简历，找实习。不知道是不是每一个计算机人，心都向往着大厂，我那时自认为水平不错，有能力有技术，应该很容易就能进去，但投了很多简历，都石沉大海。</p>
<p>  大概是 18 年 4，5 月份的时候，学校规定每个学生必须参与实习，并给出了一份公司名单。除了上海汉得外，全他娘的是新疆本地的互联网公司。这意味着除了去上海汉得外，基本只能在新疆本地待着了。</p>
<p>  18 年 12 月，前往上海参加汉得的为期三个月的实习生培训，当我坐在教室里，授课老师从基础的 HTML 讲起，周围同学基本 <code>零基础</code> 的技术水平，心里落差其实挺大的。呆了不到一个星期，我意识到这样的学习对我收效甚微。主动联系了主管，想让他安排我上手参与项目，后来就去了深圳，驻场华润置地，开发了微信企业号，万象城装修管理等几个项目，也让我慢慢了解了外包这个岗位。</p>
<p>  那时候有和师兄聊过薪资待遇问题，也有提到我毕业后，如果来这里的话，林林总总能拿到 9k 的薪资，加上各种各样的补贴和福利，最后大约一个月能拿到 12k。。当时心想好像和大厂起薪差不多了，好像还行的样子。心里就默默的接受了。</p>
<p>  毕业后，驻场北京阳光保险，技术氛围，转正定薪等与心里期望落差蛮大的，坚持了两个月，于19年 10 月 27 日裸辞了。辞职前，投了很多公司， 但都石沉大海，在 cnode 上无意看到了志毅发的西山居的招聘帖子，加了微信，发了简历后，成功的约了面试。最终面到了老白这一面，面完了最终才提到这是个外包岗位，心里多少是有气且犹豫的，但最终考虑到志毅是阿里出来的，技术水平足够牛逼，我可以学到很多东西，我最终接受了这个 offer，也想着呆满两年，再往大厂跳。</p>
<p>  在西山居，我们团队属于一个创业型团队，这意味着团队人少，工作量大，也意味着我能够快速成长，在技术上有所提升，还意味着头发一撮一撮的掉...没过几个月，得到了老白的认可，顺利的拿到了正式编。在经过几次大方向变更，最终产品定型之后，从业务中就很难获得技术上的提升了，日常开发只是满足常规的一些需求，开发几个弹窗，几个页面等等，虽然无趣，但这却是工作和生活的常态。</p>
<p>  在西山居最终还是没呆满两年，在志毅离职后，我给一些大厂投了简历，最后面着面着，考虑到个人发展，薪资待遇，技术氛围等原因，在拿到蚂蚁的 offer 后，提了离职。</p>
<p>  “你想要什么”，这个问题经常会在我的脑海中闪现，也经常想几秒钟，然后无疾而终。现在细想，我依然回答不好这个问题。<br>
  在这几年的职业生涯中，“技术”是我这个阶段的主线。只有明确目标和追求，有了主线，才不至于安于现状，止步不前。</p>
<h2 id="生活">生活</h2>
<p>  大家应该都经历过下班后，躺在沙发上刷手机，打游戏，然后不知不觉过了 12 点，睡觉前会想时间过得快，仿佛啥都没干，就没时间了。<br>
  久而久之，<strong>挺没意思，挺迷茫的</strong>。<br>
  我决定做些什么，在看完<a href="https://www.bilibili.com/video/BV1U64y1B7LE?from=search&amp;seid=3067143539344479507">《用好提醒事项，摆脱焦虑和拖延》</a> 这个视频后，决定使用“提醒事项”配合“日历”，完成日常生活的管理。</p>
<p>根据视频做了简单的规划<br>
<img src="https://aiyou.life/post-images/1638086254227.png" alt="" loading="lazy"></p>
<h3 id="记账">记账</h3>
<p>  从 9 月离开珠海后，到 10 月在杭州安顿下来，花费巨大，却也没有统计过具体花钱花在了什么地方，自己到底有多少资产。所以我第一个日程的规划，就是每天花 5 分钟的时间，记录一下今天的花销。<br>
  研究了几款记账软件，最后选择了使用微信账本小程序记账，他足够简单，使用微信付款还能自动同步账单等等。后来看到少数派推荐的这款软件 <a href="https://sspai.com/post/66133">icost</a> 试用了一下，感觉还挺不错，功能，UI等设计的很棒。只有高级功能需要付费 50 RMB，价格还算合理，但对我这种刚接触记账的小白用户来说，值得不值得入手就是另外一回事了。<br>
  这时，也有想法自己开发一个记账小程序，刚好可以实践一下自动化构建，玩一玩 docker、gitlab、自动化构建等一套流程。<br>
<img src="https://aiyou.life/post-images/1638088782119.png" alt="" loading="lazy"></p>
<p>  断断续续花了一个多星期，在修复了 Taro 的两个 Bug: <a href="https://github.com/NervJS/taro-plugin-inject/pull/9">fix: 新增组件失败</a>, <a href="https://github.com/NervJS/taro-project-templates/pull/52">fix: wxcloud模板在 ts 下跑不起来 </a>, 成功搭建起小程序项目。然后在经历了树莓派性能太低，gitlab 安装不上、内存卡读取速度太慢，命令运行太卡等挫折后，最后架构改为：在 MBP 的 docker  中安装 gitlab, 在树莓派安装 gitlab-runner 跑任务。成功在内网搭建起了一套Git 和自动化构建系统</p>
<p>内网 Gitlab 跑任务:<br>
<img src="https://aiyou.life/post-images/1638088722782.png" alt="" loading="lazy"></p>
<p>自动化构建:<br>
<img src="https://aiyou.life/post-images/1638094447340.png" alt="" loading="lazy"></p>
<p>项目截图:<br>
<img src="https://aiyou.life/post-images/1638089953253.png" alt="" loading="lazy"></p>
<h3 id="读书">读书</h3>
<p>  虽说上了这么多年的学，可还是感觉自己很空洞，说话写字没有深度。此外还觉得自己情商太低，容易说错话。所以我规划了每天读一个章节的书，第一本也就顺其自然的选择了《人性的弱点》。</p>
<p>  光读书还不够，微信读书可以勾勾画画做批注等等，此外，还需要精炼主题，了解每一章大致讲了写什么，我整理了一些关键点到 <a href="https://www.yuque.com/docs/share/9cfcc350-32ed-4163-ba7e-7aa1b43bfe5a">语雀笔记</a>上，并且打算看完后，再写一篇读后感。</p>
<p>  作为补充，还可以选几本其他类的书，一起读。我选择了常书欣的《斗贼》，常书欣文笔非常好，读起来很舒服，故事情节也很搞笑。</p>
<h3 id="技能">技能</h3>
<p>  这几年一直在用 JS 写项目，忽略了很多其他的东西。当我在“提醒事项”上填充内容时，我脑海中浮现的 Linux、Shell、服务器之类的东西，所以规划了学习 shell 脚本、Vim 快捷键、iTerm2 快捷键等。。</p>
<p>  当我学习了 shell 脚本后，马上就在公司项目中应用上了。shell 脚本完全可以在一些场景下，代替 js 脚本，且 shell 脚本执行效率相比 nodejs 高出许多。。</p>
<p>  快捷键的学习，很大的提高了我的开发效率。尤其在我学习了 vim 快捷键后，感触更深。以往用 vim 打开文件，更改某个文字时，使用方向键从左上角，一个一个键的按，按到最终目标，这样效率低，但也能用，所以就这样凑合了好久，也没有仔细了解学习快捷键。。如果在当时愿意多了解一步的话，会方便很多。。我整理了<a href="https://www.yuque.com/xdoer/cw34f7">语雀仓库</a>，大家可以看看</p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[富文本编辑器转为受控组件的一种思路]]></title>
        <id>https://aiyou.life/post/Fjnj0JDZ5/</id>
        <link href="https://aiyou.life/post/Fjnj0JDZ5/">
        </link>
        <updated>2021-11-07T14:43:27.000Z</updated>
        <summary type="html"><![CDATA[<p>本文将探讨如何将 wangEditor 改造成受控组件，并且可以与 Antd 的 Form 表单连用。其他编辑器应该类似，本文抛个砖引个玉，希望给大家带来启发。</p>
]]></summary>
        <content type="html"><![CDATA[<p>本文将探讨如何将 wangEditor 改造成受控组件，并且可以与 Antd 的 Form 表单连用。其他编辑器应该类似，本文抛个砖引个玉，希望给大家带来启发。</p>
<!-- more -->
<h2 id="前言">前言</h2>
<p>最近接到一个小需求，要将业务中的 textarea 文本框改为富文本编辑器，调研了几种编辑器后，最终选择了 wangEditor 来实现需求。由于原来的 textarea 是放在 antd 的 form 表单中使用的，因而编辑器需要改造成受控组件的形式。</p>
<h2 id="受控与非受控组件">受控与非受控组件</h2>
<p>下面先用两个示例，感受一下受控组件和非受控组件的区别:</p>
<p>非受控组件</p>
<pre><code class="language-html">&lt;input /&gt;
</code></pre>
<p>受控组件</p>
<pre><code class="language-html">const [value, setValue] = useState('')

&lt;input value={value} onChange={(e) =&gt; setValue(e.detail.value)}/&gt;
</code></pre>
<p>简单来讲，受控组件可以由外部状态影响到输入框的输入值。</p>
<h2 id="组件封装">组件封装</h2>
<p>回到编辑器，他本身就类似于上面的非受控组件，但相比 input, 他没有 value 和 onChange 来改变编辑器的内容。幸运的是，他提供了 <code>editor.txt.html()</code> 方法可以 <strong>获取</strong> 和 <strong>设置</strong> 编辑器的内容，同时也提供了 <code>editor.config.onchange</code> 钩子函数来监听用户输入。</p>
<p>于是很容易封装出这样的代码</p>
<pre><code class="language-tsx">&lt;!------------Editor.tsx----------------&gt;
import { useEffect, useRef } from 'react'
import E from 'wangeditor'

interface Props {
  value: string
  onChange(v: string): void
}

export default ({ value, onChange }: Props) =&gt; {
  const editorEle = useRef(null)
  const editorRef = useRef&lt;E | null&gt;(null)

  useEffect(() =&gt; {
    const editor = new E(editorEle.current)
    editorRef.current = editor

    editor.config.onchange = onChange
    editor.create()
  }, [])

  useEffect(() =&gt; {
    editorRef.current?.txt.html(value)
  }, [value])

  return &lt;div ref={editorEle}&gt;&lt;/div&gt;
}
</code></pre>
<h2 id="问题引出">问题引出</h2>
<p>使用 useEffect 监听 value 值，调用  <code>editor.txt.html()</code> 方法来更新编辑器的输入框内容。想法没什么问题，是实际使用，你会发现各种各样的问题，比如: <a href="https://github.com/wangeditor-team/wangEditor/issues/3666">如何保留光标位置</a> ，性能较差等等。</p>
<h2 id="分析问题">分析问题</h2>
<p>要解决这个问题，首先考虑一下，什么情况下 value 会变化？</p>
<p>如果 value 值只由 onChange 引起变化，那其实编辑器中输入框的值永远是与 value 值是相等的，即 <code>value === editor.txt.html()</code>, 因而没有必要再去手动设置一遍 <code>editor.txt.html(value)</code>, 所以上面代码中，监听 value 值这一段代码可以去掉了。</p>
<p>但情况往往没有这么简单，如果初始时，编辑器里的值是依赖接口返回的，则还是需要去监听 value 值的变化，组件里面，没有办法区分你数据是怎么来的，需不需要进行  <code>editor.txt.html(value)</code> 的操作。</p>
<pre><code class="language-tsx">&lt;!------------App.tsx----------------&gt;
import { useEffect, useState } from 'react'
import Editor from './Editor'

function App() {
  const [value, setValue] = useState('')

  useEffect(() =&gt; {
    // 编辑器初始值依赖接口返回
    fetch('http://localhost:3000').then(res =&gt; res.text()).then(setValue)
  }, [])

  return (
    &lt;div className=&quot;App&quot;&gt;
      &lt;Editor value={value} onChange={setValue} /&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2 id="解决方案">解决方案</h2>
<p><strong>上面也提到了，如果 value 值是由 onChange 引起的，那么 <code>value</code> 永远等于 <code>editor.txt.html()</code>, 如果不等于，一定就是由外部因素引起 value 的变化，由外部引起时，再调用 <code>editor.txt.html(value)</code> 就好了。</strong></p>
<pre><code class="language-tsx">export default ({ value, onChange }: Props) =&gt; {
  
  // ...省略部分代码

  useEffect(() =&gt; {
    if (value !== editorRef.current?.txt.html()) {
      editorRef.current?.txt.html(value)
    }
  }, [value])

  return &lt;div ref={editorEle}&gt;&lt;/div&gt;
}
</code></pre>
<p>可能有人会疑惑，这种方法还是没解决保留光标位置的问题啊。</p>
<p>这种方法确实没解决这个问题，但是还请考虑，什么情况下，需要除去 onChange 方法外，去改变 value 值呢? 初始化数据？reset 表单？这些是不是没必要保留光标位置呢？</p>
<h2 id="代码示例">代码示例</h2>
<pre><code class="language-tsx">import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'
import E from 'wangeditor'

interface Props {
  defaultValue?: string
  value: string
  onChange(v: string): void
}

export default forwardRef(({ value, onChange, defaultValue }: Props, ref) =&gt; {
  const editorEleRef = useRef(null)
  const editorRef = useRef&lt;E | null&gt;(null)

  useEffect(() =&gt; {
    const editor = new E(editorEleRef.current)
    editorRef.current = editor

    editor.config.onchange = onChange

    if(defaultValue) editor.txt.html(defaultValue)

    editor.create()

    return () =&gt; {
      editor.destroy()
    }
  }, [])

  useEffect(() =&gt; {
    if (value !== editorRef.current?.txt.html()) {
      editorRef.current?.txt.html(value)
    }
  }, [value])

  useImperativeHandle(ref, () =&gt; ({
    value: editorRef.current?.txt.html() || ''
  }))

  return &lt;div ref={editorEleRef}&gt;&lt;/div&gt;
})
</code></pre>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[TS 的一些高级类型写法]]></title>
        <id>https://aiyou.life/post/zPGtmQi-5/</id>
        <link href="https://aiyou.life/post/zPGtmQi-5/">
        </link>
        <updated>2021-09-03T09:28:41.000Z</updated>
        <summary type="html"><![CDATA[<p>记录自己想到的和遇到的收集一些高级 TS 类型的代码</p>
]]></summary>
        <content type="html"><![CDATA[<p>记录自己想到的和遇到的收集一些高级 TS 类型的代码</p>
<!-- more -->
<h2 id="数组不为空">数组不为空</h2>
<p>一道面试题，如何定义一个不是空数组的数组？</p>
<pre><code class="language-ts">// 怎么定义类型，约束数组中值至少有一项
const a: number[] = []
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">// 解法一
type NotAmptyArray&lt;T&gt; = [T, ...T[]]

// 解法二
type NotAmptyArray&lt;T&gt; = T[] &amp; { 0: T }
</code></pre>
</details>
<h2 id="全部可选的参数对象至少包含一个-key">全部可选的参数对象至少包含一个 key</h2>
<p>自己想出来的一个问题，语言不好描述，看下面代码</p>
<pre><code class="language-ts">interface A {
    a?: number
    b?: boolean
}

declare function main(opt: A): void

// 怎么定义一个类型，使得参数对象中至少包含一个 key？
main({})
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">interface A {
    a: number
    b: boolean
}

type AtLeastOne&lt;T&gt; = {
    [K in keyof T]: Pick&lt;T, K&gt; &amp; Partial&lt;Omit&lt;T, P&gt;&gt;
}[keyof T]

declare function main(opt: AtLeastOne&lt;A&gt;): void

main({ a: 1 })
</code></pre>
</details>
<h2 id="给定的类型可选">给定的类型可选</h2>
<p>在下面的实例中，实现 SetOptional，可使得给定的 key 为可选</p>
<pre><code class="language-ts">type Foo = {
  a: number;
  b?: string;
  c: boolean;
}

// 实现 SetOptional
type SomeOptional = SetOptional&lt;Foo, 'a' | 'b'&gt;;

// type SomeOptional = {
//  a?: number; // 该属性已变成可选的
//  b?: string; // 保持不变
//  c: boolean; 
// }
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">// 解法一
type SetOptional&lt;T, N extends keyof T&gt; = {
  [P in keyof T]: Partial&lt;Pick&lt;T, N&gt;&gt; &amp; Pick&lt;T, Exclude&lt;keyof T, N&gt;&gt;
}[keyof T]

// 解法二
type Simplify&lt;T&gt; = {
  [P in keyof T]: T[P]
}
type SetOptional&lt;T, K extends keyof T&gt; =
 Simplify&lt;Partial&lt;Pick&lt;T, K&gt;&gt; &amp; Pick&lt;T, Exclude&lt;keyof T, K&gt;&gt;&gt;
</code></pre>
</details>
<h2 id="条件-pick">条件 Pick</h2>
<p>根据值类型进行筛选</p>
<pre><code class="language-ts">interface Example {
 a: string;
 b: string | number;
 c: () =&gt; void;
 d: {};
}

// 测试用例：
type StringKeysOnly = ConditionalPick&lt;Example, string&gt;;
//=&gt; {a: string}
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">// 首先将类型对应的 key 找出来，然后再进行 Pick
type ConditionalKeys&lt;T, Condition&gt; = {
  [P in keyof T]: T[P] extends Condition ? P : never
}[keyof T]

type ConditionalPick&lt;T, Condition&gt; = Pick&lt;T, ConditionalKeys&lt;T, Condition&gt;&gt;
</code></pre>
</details>
<h2 id="函数插入参数">函数插入参数</h2>
<p>为已有的函数类型增加指定类型的参数，新增的参数名是 x，将作为新函数类型的第一个参数</p>
<pre><code class="language-ts">type Fn = (a: number, b: string) =&gt; number
type AppendArgument&lt;F, A&gt; = // 你的实现代码

type FinalFn = AppendArgument&lt;Fn, boolean&gt; 
// (x: boolean, a: number, b: string) =&gt; number
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">type Fn = (a: number, b: string) =&gt; number

// TS 中内置了 Parameters， 和 ReturnType 可以便捷的获取函数的参数和响应类型
type AppendArgument&lt;F extends ((...args: any) =&gt; any), A&gt; = (x: A, ...args: Parameters&lt;F&gt;) =&gt; ReturnType&lt;F&gt;

type FinalFn = AppendArgument&lt;Fn, boolean&gt;

</code></pre>
</details>
<h2 id="扁平数组">扁平数组</h2>
<pre><code class="language-ts">type NaiveFlat&lt;T extends any[]&gt; = // 你的实现代码

// 测试用例：
type NaiveResult = NaiveFlat&lt;[['a'], ['b', 'c'], ['d']]&gt;
// NaiveResult的结果： &quot;a&quot; | &quot;b&quot; | &quot;c&quot; | &quot;d&quot;
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">type NaiveFlat&lt;T extends any[]&gt; = {
  [P in keyof T]: T[P] extends any[] ? NaiveFlat&lt;T[P]&gt;: T[P]
}[number]
</code></pre>
</details>
<h2 id="强制对象范围">强制对象范围</h2>
<pre><code class="language-ts">type SomeType =  {
  prop: string
}

// 更改以下函数的类型定义，让它的参数只允许严格SomeType类型的值
function takeSomeTypeOnly(x: SomeType) { return x }

// 测试用例：
const x = { prop: 'a' };
takeSomeTypeOnly(x) // 可以正常调用

const y = { prop: 'a', addditionalProp: 'x' };
takeSomeTypeOnly(y) // 将出现编译错误
</code></pre>
<details>
<summary>点击查看答案</summary>
<pre><code class="language-ts">type Exclusive&lt;T1, T2 extends T1&gt; = {
  [P in keyof T2]: P extends keyof T1 ? T2[P] : never
}

function takeSomeTypeOnly&lt;T extends SomeType&gt;(x: Exclusive&lt;SomeType, T&gt;) { return x }
</code></pre>
</details>]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Restful-API 的一种动态生成数据类型的方法]]></title>
        <id>https://aiyou.life/post/QqaRhC3Gf/</id>
        <link href="https://aiyou.life/post/QqaRhC3Gf/">
        </link>
        <updated>2021-08-31T03:34:31.000Z</updated>
        <summary type="html"><![CDATA[<p>使用 TypeScript 开发前端项目，完善的类型批注是非常提升开发效率的。然而，当遇到 Restful，似乎只能为 Restful 返回的 JSON 数据手动书写类型，随着接口越来越多，手写类型是繁琐且低效的。 有没有一种简单的方式，可以拿到返回数据的类型呢？</p>
]]></summary>
        <content type="html"><![CDATA[<p>使用 TypeScript 开发前端项目，完善的类型批注是非常提升开发效率的。然而，当遇到 Restful，似乎只能为 Restful 返回的 JSON 数据手动书写类型，随着接口越来越多，手写类型是繁琐且低效的。 有没有一种简单的方式，可以拿到返回数据的类型呢？</p>
<!-- more -->
<h2 id="json-类型文件生成">JSON 类型文件生成</h2>
<h3 id="json-类型">JSON 类型</h3>
<p>Json 中数据类型有 6 种: string 、number、boolean、array、object、null</p>
<p>其中 string、number、boolean 的类型可以直接使用 <code>typeof</code> 判别类型。</p>
<p>null 有些复杂，它可能是其他 5 中类型中的一种，无法判断具体是什么类型，因而只能填充 any</p>
<p>对于 object，它可能由 Json 的 6 种数据结构组成，可以使用递归遍历的方式，来判断 value 的类型</p>
<p>而对于 array ，array 中的每一项数据结构应当都是相同的，因而只需要取出第一项进行处理，处理逻辑与上述几种类型相同。</p>
<h3 id="文件生成">文件生成</h3>
<p>可以使用 node fs api，利用拼接字符串的形式，将 JSON 类型处理后，输出到类型文件中。这样简单且有效，但不那么优雅，且易出错。</p>
<p>可以借助 <a href="https://ts-morph.com/">ts-morph</a> 这个库，来完成类型的生成和导出。</p>
<p>ts-morph 使用伪代码如下:</p>
<pre><code class="language-text">const project = createProject()
project.addInterface({ name, value }).setIsExport(true)
saveProject(project)
</code></pre>
<p>相比 fs API，ts-morph 使用更简单</p>
<h2 id="restful-整合">Restful 整合</h2>
<p>可以根据 JSON 数据生成类型文件后，很容易想到，在请求库的拦截器中，拦截响应，执行 JSON 类型文件生成。但值得注意的是，前端项目中，Node API 不能使用，因为你的代码是运行在浏览器的。那么怎么解决这个问题呢？</p>
<h3 id="类型生成器脚本">类型生成器脚本</h3>
<p>既然前端项目中不能集成JSON类型文件生成工具，那么可以编写 Node 脚本来解决问题。后端提供一个接口后，前端新增一个接口，脚本配置文件也要注册一个接口，最后运行一下脚本即可。</p>
<p>那么看看脚本需要完成哪些功能。</p>
<p>首先脚本需要集成一个请求库，用以发起请求，接收服务端的 JSON 数据。</p>
<p>然后还要集成上面的 JSON类型文件生成脚本。</p>
<p>此外，还需要维护一份配置文件，文件中要有请求参数列表，用以动态生成类型文件。为了避免同时发起的请求数量太多，导致电脑死机，或者服务端宕机，还要对请求进行并发控制。</p>
<p>每次执行脚本，所有请求都会再发送一遍，所以还要考虑检测文件是否生成，再去请求。</p>
<p>考虑到可维护性，建议单独维护一个 URL 的映射文件，在Node脚本和前端项目，引用 URL 文件的URL 地址。</p>
<p>有了这样一个脚本，每次新增一个接口时，需要在配置文件中配一下接口和请求参数，然后手动执行一下脚本。这样也不太方便，可以使用 <a href="https://github.com/paulmillr/chokidar">chokidar</a> 监听文件变更，使用 shelljs 来执行脚本。</p>
<p>可以看到，上面的步骤繁琐且复杂，维护这样一个复杂配置文件，会让人望而却步。并且这样的配置文件对于一些复杂的请求，涉及到的 Token 校验， Post 的 Body 处理，响应的 Data 的处理等等都要区别与前端项目，再单独处理一遍。</p>
<p>有没有更好的办法，来完成类型生成的目的？</p>
<h3 id="server-clinet-类型生成器">Server-Clinet 类型生成器</h3>
<p>写这样一个脚本，主要的难点在于Node脚本怎么便捷的拿到前端项目的响应数据，也就是前端拿到数据后怎么通知到脚本？</p>
<p>这么一想，事情就简单了，如果 Node 脚本中开启一个 HTTP Server，前端拿到数据后，再向 HTTP Server 发起一个 POST 请求，将一些参数携带过去，指挥 HTTP Server 向目标目录生成类型文件即可。</p>
<p>但这一套流程还有个缺点，类型文件是“运行时”生成的，生成类型文件前，需要前端项目先调用一次请求。但是，这一点缺点无伤大雅，开发代码时，肯定需要先测试接口能不能通什么的。</p>
<h2 id="效果展示">效果展示</h2>
<p>基于几天的尝试，我开发了几个库，完成了这样一件事情，最后看 demo 的效果，还不错。</p>
<h3 id="demo-项目">Demo 项目</h3>
<p>我基于 Vite React TypeScript 写了一个 demo 项目：<a href="https://github.com/xdoer/restful-types-generate-example">restful-types-generate-example</a>。</p>
<p>clone 项目后，运行 yarn 安装, yarn dev 启动项目，点击页面按钮，发起请求后即可看到效果。</p>
<figure data-type="image" tabindex="1"><img src="https://aiyou.life/post-images/1630407294021.gif" alt="" loading="lazy"></figure>
<h3 id="jsontypesgenerator">JsonTypesGenerator</h3>
<p><a href="https://github.com/xdoer/json-types-generator">json-types-generator</a> 是根据第一小节中介绍的原理完成的</p>
<p>使用方式如下:</p>
<pre><code class="language-ts">import jsonTypesGenerator from 'json-types-generator'

const json = { a: { b: 1, c: { d: true } } }

jsonTypesGenerator({
   data: json,
   outPutPath: '/User/xdoer/types.ts',
   rootInterfaceName: 'ChinaRegion',
   customInterfaceName(key, value, data) {
      if (key === 'b') return 'Province'
   },
})
</code></pre>
<p>上面的代码，将会在 <code>/User/xdoer/types.ts</code> 文件中生成导出 interface 为 <code>ChinaRegion</code> 的类型文件，产生的中间 inteface 名称为 <code>Province</code>，中间产物默认的 interface 名称为 key 的大写</p>
<pre><code class="language-ts">&lt;!----/User/xdoer/types.ts----&gt;
export interface ChinaRegion {
    a: Province
}

export interface Province {
    b: number
    c: C
}

export interface C {
    d: boolean
}
</code></pre>
<h3 id="responsetypesserver">ResponseTypesServer</h3>
<p><a href="https://github.com/xdoer/PreQuest/tree/main/packages/response-types-server">response-types-server</a> 是上文提到的 Server-Clinet 类型生成器 中的 Server 部分。只需要向这个Server 发送 POST 请求，即可生成类型。</p>
<p>使用方式如下:</p>
<pre><code class="language-ts">import server from '@prequest/response-types-server'

// 默认开启的端口为 10086
server()

// 你可以通过传参指定端口
server({ port: 10010 })
</code></pre>
<p>发送的请求，路径任意， POST 请求参数为:</p>
<table>
<thead>
<tr>
<th>参数</th>
<th>类型</th>
<th>含义</th>
</tr>
</thead>
<tbody>
<tr>
<td>outPutDir</td>
<td>string</td>
<td>类型文件输出目录</td>
</tr>
<tr>
<td>outPutName</td>
<td>string</td>
<td>文件名称</td>
</tr>
<tr>
<td>overwrite</td>
<td>boolean</td>
<td>文件可复写</td>
</tr>
<tr>
<td>data</td>
<td>Json</td>
<td>要解析的 Json 数据</td>
</tr>
<tr>
<td>interfaceName</td>
<td>string</td>
<td>导出的接口名称</td>
</tr>
</tbody>
</table>
<h3 id="responsetypesclient">ResponseTypesClient</h3>
<p><a href="https://github.com/xdoer/PreQuest/tree/main/packages/response-types-client">response-types-client</a> 是上文提到的 Server-Clinet 类型生成器 中的 Client 部分。它是一个中间件 Wrapper，只要将其注册到请求库中间件中，即可发起请求。</p>
<p>下面的 demo 使用了我自己封装的请求库 PreQuest，基于 Koa 中间件模型的请求库应该都可以使用，比如说 Umi-Request。对于 Axios，需要自己在拦截器中实现，也非常容易。</p>
<p>使用方式如下：</p>
<pre><code class="language-ts">import { create, Request, Response } from '@prequest/xhr'
import generateMiddleware, { TypesGeneratorInject } from '@prequest/response-types-client'

// 生成中间件
const middleware = generateMiddleware&lt;Request, Response&gt;({
  enable: process.env.NODE_ENV === 'development',
  httpAgent: create({ path: 'http://localhost:10010/' }),
  outPutDir: 'src/api-types'
  parseResponse(res) {
    // res 应当返回接口 data 数据
    return res as any
  },
  typesGeneratorConfig(req, res) {
    const { path } = req
    const { data } = res

    if (!path) throw new Error('path not found')

    // 根据请求路径生成文件名和类型导出名称
    const outPutName = path.replace(/.*\/(\w+)/, (_, __) =&gt; __)
    const interfaceName = outPutName.replace(/^[a-z]/, g =&gt; g.toUpperCase())

    return {
      data,
      outPutName,
      interfaceName,
      overwrite: true,
    }
  },
})

// 注入 TypesGeneratorInject， 可在请求时，根据 rewriteType 参数强制重新生成类型文件
export const prequest = create&lt;TypesGeneratorInject, {}&gt;({ baseURL: 'http://localhost:3000' })
// 注册中间件
prequest.use(middleware)
</code></pre>
<h3 id="responsetypesgenerator">ResponseTypesGenerator</h3>
<p>此外，还有基于上文 &quot;类型生成器脚本&quot; 一节中的原理，进行了一个失败的尝试:<a href="https://github.com/xdoer/PreQuest/tree/main/packages/response-types-generator">response-types-generator</a>，也一并放到这里，感兴趣的可以看看</p>
<h2 id="结语">结语</h2>
<p>以上基于我浅薄的学识进行的一些对 Restful 响应的 JSON 数据类型生成的一些探索，如果您发现了文中的一些错误之处，或者有更简便的方式生成类型文件，欢迎在评论里提出来，大家一起探讨。</p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[几个Demo 看懂 ESM 与 CommonJS 差异]]></title>
        <id>https://aiyou.life/post/SV2erlm_4/</id>
        <link href="https://aiyou.life/post/SV2erlm_4/">
        </link>
        <updated>2021-07-21T12:20:41.000Z</updated>
        <summary type="html"><![CDATA[<p>CommonJS 作为 Node 端模块导入的事实方案，与 ES 标准提出的 ES Module 有何差异呢？</p>
]]></summary>
        <content type="html"><![CDATA[<p>CommonJS 作为 Node 端模块导入的事实方案，与 ES 标准提出的 ES Module 有何差异呢？</p>
<!-- more -->
<h2 id="一-commonjs-和-esm-都可以对引入的对象进行赋值">一、CommonJS 和 ESM 都可以对引入的对象进行赋值</h2>
<h3 id="11-commonjs-模块">1.1 CommonJS 模块</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.js ---&gt;
const { a } = require('./test')

setTimeout(() =&gt; {
  console.log(a.value)
}, 200)

console.log(a.value)

&lt;!--- 定义模块文件 test.js ---&gt;
const a = { value: 1 }

setTimeout(() =&gt; {
  a.value = 2
}, 100)

module.exports = { a }
</code></pre>
<p><strong><em>输出: 1, 2</em></strong></p>
<h3 id="12-esm-模块">1.2 ESM 模块</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.mjs ---&gt;
import  { a }  from './test.mjs'

setTimeout(() =&gt; {
  console.log(a.value)
}, 200)

console.log(a.value)

&lt;!--- 定义模块文件 test.mjs ---&gt;
let a = { value: 1 }

setTimeout(() =&gt; {
  a.value = 2
}, 100)

export { a }
</code></pre>
<p><strong><em>输出: 1, 2</em></strong></p>
<h3 id="13-动态-esm">1.3 动态 ESM</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.mjs ---&gt;
const { a } = await import('./test.mjs')

setTimeout(() =&gt; {
  console.log(a.value)
}, 200)

console.log(a.value)

&lt;!--- 定义模块文件 test.mjs ---&gt;
let a = { value: 1 }

setTimeout(() =&gt; {
  a.value = 2
}, 100)

export { a }
</code></pre>
<p><strong><em>输出: 1, 2</em></strong></p>
<h2 id="二-commonjs-模块输出的是一个值的拷贝esm-模块输出的是值的引用">二、CommonJS 模块输出的是一个值的拷贝，ESM 模块输出的是值的引用</h2>
<h3 id="21-commonjs-模块">2.1 CommonJS 模块</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.js ---&gt;
const { a } = require('./test')

setTimeout(() =&gt; {
  console.log(a)
}, 200)

console.log(a)

&lt;!--- 定义模块文件 test.js ---&gt;
let a = 1

setTimeout(() =&gt; {
  a = 2
}, 100)

module.exports = { a }
</code></pre>
<p><strong><em>输出: 1, 1</em></strong></p>
<h3 id="22-esm-模块">2.2 ESM 模块</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.mjs ---&gt;
import { a }  from './test.mjs'

setTimeout(() =&gt; {
  console.log(a)
}, 200)

console.log(a)

&lt;!--- 定义模块文件 test.mjs ---&gt;
let a = 1

setTimeout(() =&gt; {
  a = 2
}, 100)

export { a }
</code></pre>
<p><strong><em>输出: 1, 2</em></strong></p>
<h3 id="23-动态-esm">2.3 动态 ESM</h3>
<pre><code class="language-js">&lt;!--- 定义入口文件 main.mjs ---&gt;
const { a } = await import('./test.mjs')

setTimeout(() =&gt; {
  console.log(a)
}, 200)

console.log(a)

&lt;!--- 定义模块文件 test.mjs ---&gt;
let a = 1

setTimeout(() =&gt; {
  a = 2
}, 100)

export { a }
</code></pre>
<p><strong><em>输出: 1, 1</em></strong></p>
<h2 id="三-es6-module-只存只读不能改变其值指针指向不能变">三、ES6 Module 只存只读，不能改变其值，指针指向不能变</h2>
<pre><code class="language-js">import * as a from './test.mjs'

a = 2 // throw a error
</code></pre>
<p>import 导入的值类似 const 定义，值不能改变</p>
<h2 id="四-commonjs-模块是运行时加载es6-模块是编译时输出接口">四、CommonJS 模块是运行时加载，ES6 模块是编译时输出接口</h2>
<h2 id="五-commonjs-模块的-require是同步加载模块es6-模块的-import-命令是异步加载">五、CommonJS 模块的 require()是同步加载模块，ES6 模块的 import 命令是异步加载</h2>
<pre><code class="language-js">const { a } = await import('./test.mjs')
</code></pre>
<h2 id="六-commonjs-模块是运行时加载es6-模块是编译时输出接口">六、CommonJS 模块是运行时加载，ES6 模块是编译时输出接口</h2>
<p>CommonJS 加载的是一个对象（即 module.exports 属性），该对象只有在脚本运行完才会生成。而 ESM 不是对象，它的对外接口只是一种静态定义，在代码静态解析阶段就会生成</p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[Taro 自定义 showModal]]></title>
        <id>https://aiyou.life/post/UNWTwtWxa/</id>
        <link href="https://aiyou.life/post/UNWTwtWxa/">
        </link>
        <updated>2021-06-26T07:53:18.000Z</updated>
        <summary type="html"><![CDATA[<p>微信小程序提供了很多类似 <code>wx.showModal</code>、<code>wx.showLoading</code> 这类 API，这类 API 虽然方便使用，但是样式丑陋，往往不满足我们的需求。</p>
<p>有没有办法让我们的自定义弹窗、loading 等可以通过类似微信的这种 API 进行随心所欲的调用呢？</p>
]]></summary>
        <content type="html"><![CDATA[<p>微信小程序提供了很多类似 <code>wx.showModal</code>、<code>wx.showLoading</code> 这类 API，这类 API 虽然方便使用，但是样式丑陋，往往不满足我们的需求。</p>
<p>有没有办法让我们的自定义弹窗、loading 等可以通过类似微信的这种 API 进行随心所欲的调用呢？</p>
<!-- more -->
<p>首先放一下效果图:<br>
<img src="https://aiyou.life/post-images/1624694046612.gif" alt="" loading="lazy"><br>
可以看到只在 Index 页面写了一个按钮，就可以触发打开弹窗。</p>
<p>这是怎么做到的呢？</p>
<h2 id="目标">目标</h2>
<p>首先观察一下特点：</p>
<pre><code class="language-ts">wx.showModal({
  title: &quot;提示&quot;,
  content: &quot;操作不合法&quot;,
});
</code></pre>
<blockquote>
<ul>
<li>1、API 式调用.</li>
<li>2、全局性.在小程序任意地方都可以调用</li>
</ul>
</blockquote>
<h2 id="api-式的调用">API 式的调用</h2>
<p>当进行这样一个调用时，我们需要将数据和状态，通过一定的方式，传入到某个组件中，组件再进行响应。传递数据的方式有 props 与 context。传递 props 方案首先排除了，因为你不可能每个组件要传入弹窗的 props，那么使用 context 方案呢？使用 context 需要在应用顶层提供一个 Provider，将所有弹窗数据和显隐状态，与修改数据的方法传入 Provider 中，在 Consumer 中 map 出所有弹窗数据，在需要打开或关闭弹窗的地方，使用 <code>this.context</code> 或者 <code>useContext</code> 拿到修改数据方法，然后才能控制弹窗状态。</p>
<p>下面提供了一份使用 context 方案的伪代码</p>
<pre><code class="language-tsx">const LayerContext = createContext(null)

&lt;!---- app.tsx 入口文件 ----&gt;
export default function (props) {
  const [config, setConfig] = useState([])

  return (
    &lt;LayerContext.Provider value={{config, setConfig}}&gt;
      {props.children}
    &lt;/LayerContext.Provider&gt;
  )
}

&lt;!---- wrapper.tsx 弹窗 wrapper 组件----&gt;
export function Wrapper() {
  return (
    &lt;LayerContext.Consumer&gt;
      {
        ({config}) =&gt; {
          return (
            &lt;&gt;
              {
                config.map(c =&gt; {
                  return (
                    &lt;Layer config={c} /&gt;
                  )
                })
              }
            &lt;/Layer&gt;
          )
        }
      }
    &lt;/LayerContext.Consumer&gt;
  )
}

&lt;!---- 页面文件----&gt;
export default function () {
  const { setConfig } = useContext(LayerContext)

  function open() {
    setConfig((d) =&gt; d.concat[{ name: 'a' ,visible: true, data: 1 }])}
  }

  return (
    &lt;View&gt;
      &lt;View onClick={() =&gt; open()}&gt;打开 A 弹窗&lt;/View&gt;
      &lt;Wrapper /&gt;
    &lt;/View&gt;
  )
}
</code></pre>
<p>对于 wrapper 组件，需要引入到每一个页面文件，调用弹窗时使用 <code>useContext</code> 也可以接受，但一定注意优化，任何一处 <code>setConfig</code> 都会导致引入 <code>useContext(LayerContext)</code> 的组件或页面重新渲染。</p>
<p>怎么避免这个问题？</p>
<p>如果能将顶层 app.tsx 中的 <code>setConfig</code> 存到外部，每次从外部文件引入 setConfig 方法调用、不直接使用 useContext，并配合 memo 就可以解决这个问题</p>
<p>伪代码如下:</p>
<pre><code class="language-ts">&lt;!---- useStore 自定义 hook ----&gt;
export let setLayerConfig

export function useStore(initValue) {
  const [config, setConfig] = useState(initValue)
  setLayerConfig = setConfig
  return [config, setConfig]
}

&lt;!---- app.tsx 入口文件 ----&gt;
export default function (props) {
  const [config, setConfig] = useStore(layers)
  return (
    &lt;LayerContext.Provider value={{config, setConfig}}&gt;
      {props.children}
    &lt;/LayerContext.Provider&gt;
  )
}
</code></pre>
<p>要打开弹窗，只需要引入并调用 <code>setLayerConfig</code> 即可。</p>
<pre><code class="language-ts">export default function () {

  function open() {
    setLayerConfig((d) =&gt; d.concat[{ name: 'a' ,visible: true, data: 1 }])}
  }

  return (
    &lt;View&gt;
      &lt;View onClick={() =&gt; open()}&gt;打开 A 弹窗&lt;/View&gt;
      &lt;Wrapper /&gt;
    &lt;/View&gt;
  )
}
</code></pre>
<p>如果将每一个 <code>useState</code> 的 <code>data</code> 和 <code>setData</code> 存到外部，并为其分配一个标识，那么我们就可以在任意地方根据标识拿到 useState 中的数据和方法。</p>
<p>基于此，我们封装了一套简单易用的状态管理工具 <a href="https://github.com/xdoer/StateBus">StateBus</a></p>
<p>简易实现如下:</p>
<pre><code class="language-ts">class StateBus&lt;T = any&gt; {

  constructor(private state?: T | (() =&gt; T)) { }

  private listeners: RDispatch&lt;T&gt;[] = []

  private subscribe(listener: RDispatch&lt;T&gt;) {
    this.listeners.push(listener)
  }

  private unsubscribe(listener: RDispatch&lt;T&gt;) {
    const idx = this.listeners.findIndex(fn =&gt; fn === listener)
    if (idx !== -1) this.listeners.splice(idx, 1)
  }

  // 发布消息
  setState(data) {
      this.listeners.forEach(i =&gt; i(data))
  }

  useState() {
    const [data, setData] = useState&lt;T&gt;(this.state)

    useEffect(() =&gt; {
      this.subscribe(setData)

      return () =&gt; {
        this.unsubscribe(setData)
      }
    }, [])

    return [data, this.setState.bind(this)] as [T, RDispatch&lt;T&gt;]
  }
}
</code></pre>
<p>根据我们设计的状态管理工具，就可以完全摒弃 context 的方案了。</p>
<p>那么怎么使用呢？</p>
<p>首先抽象出一个 Layer 的概念，包含了 modal、popup、toast 等浮层类元素。</p>
<p>设计 Layer 元素的数据结构:</p>
<pre><code class="language-ts">interface Layer&lt;T&gt; {
    visible: boolean    // 浮层显影状态
    model: T    // 传入浮层元素的数据
    key: string     //  Layer 可层叠，所以需要 key 来标识
}
</code></pre>
<p>用我们上面的状态管理来存储每一个 Layer 元素实例:</p>
<pre><code class="language-ts">class LayerService&lt;T&gt; {
  state = new StateBus([])

  open(data: T, key: string) {
    this.state.setState((prev) =&gt; prev.concat([{ model: data, visible: true, key }]))
  }
}

const layerService = new LayerService()
</code></pre>
<p>可以看到，每次调用 open 时会调用 StateBus 的 setState，向其中 push 一个 Layer 实例。此时订阅了 <code>layerService.useState</code> 的组件，接收到消息后，会rerender 组件。根据每一个 Layer config 的 visible 状态，打开/关闭 Layer 元素即可。</p>
<p>这个组件怎么写呢？</p>
<pre><code class="language-tsx">export const LayerContainer = () =&gt; {
    const [state] = layerService.useState()

    return (
        &lt;View&gt;
            {
                state.map(i =&gt; {
                    switch (i.model.type) {
                        case 'popup':  return &lt;Popup config={i} /&gt;
                    }
                    return &lt;Toast config={i}/&gt;
                })
            }
        &lt;/View&gt;
    )
}
</code></pre>
<p>在 Popup 或者 Toast 等组件中，根据 visible 判断显影，根据 model 拿到要显示的数据。</p>
<p>在每一个页面的最底部，引入 LayerContainer 组件后，就可以通过 <code>layerService.open</code> 打开各种浮层元素了。</p>
<pre><code class="language-tsx">export default function () {

    function open() {
        layerService.open(
            {
                title: '标题',
                content: &lt;View&gt;弹窗内容&lt;/View&gt;,
                type: 'popup'
            },
            'popup-1'
        )
    }

  return (
    &lt;View&gt;
      &lt;View onClick={() =&gt; open()}&gt;打开 popup&lt;/View&gt;
      &lt;LayerContainer /&gt;
    &lt;/View&gt;
  )
}
</code></pre>
<h2 id="全局调用">全局调用</h2>
<p>小程序中没有办法定义一个全局组件，只能将组件引入到每一个页面中。</p>
<p>借助 webpack-loader，我们可以实现每个页面自动注入组件的能力。</p>
<p>我设计了一个 webpack-loader，来完成这样的事情 <a href="https://github.com/xdoer/taro-inject-component-loader">taro-inject-component-loader</a></p>
<p>注入后的每一个页面，都引入了弹窗组件，因而可以在任意地方进行 layerService 弹窗的调用。</p>
<h2 id="结语">结语</h2>
<p>上面简单介绍了 Taro React 小程序中，浮层类元素的打开方案。实际上，代码还有很多优化空间，限于篇幅，没有把每个步骤细细写来。但基本原理什么的都已经讲清楚了，希望大家有所收获。</p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[写一个倒计时😢？]]></title>
        <id>https://aiyou.life/post/iWhkaOqqO/</id>
        <link href="https://aiyou.life/post/iWhkaOqqO/">
        </link>
        <updated>2021-06-01T08:20:34.000Z</updated>
        <summary type="html"><![CDATA[<p>如何写一个高性能、易扩展、易用的计时器？</p>
]]></summary>
        <content type="html"><![CDATA[<p>如何写一个高性能、易扩展、易用的计时器？</p>
<!-- more -->
<p>某日，接到了产品需求，说要加一个抢购列表页面，列表中每一项要加一个抢购倒计时，没多想，使用 <code>setInterval</code> 快速实现了。</p>
<p>随着列表中项目越来越多，各个项目的倒计时越来越不准，且页面变的越来越卡。</p>
<p>其实很容易想到，n 多个 <code>setInterval</code> 实例同时运行，阻塞了 JS 线程，导致页面越来越卡，计时器计时出现了偏差。有没有办法用一个 <code>setInterval</code> 实例，进行多个倒计时呢?</p>
<p>首先看一下 <code>setInterval</code> 用法:</p>
<pre><code class="language-js">setInterval(() =&gt; {
    // callback
}, 1000)
</code></pre>
<p>如果在 <code>setInterval</code> 中执行多个回调函数，那么就可以实现我们的需求。</p>
<p>简单实现一下:</p>
<pre><code class="language-js">class Timer {
    private timerId
    private cbs = []
    private cbId = 0

    constructor(delay) {
        this.delay = delay
    }

    private start() {
        this.timerId = setInterval(() =&gt; {
            this.cbs.forEach(item =&gt; {
                item.cb()
            })
        }, this.delay)
    }

    private stop() {
        clearInterval(this.timerId)
    }

    add(cb) {
        const id = this.cbId++
        this.cbs.push({ cb, id })
        if(!this.timerId) this.start()
        return id
    }

    remove(cbId) {
        const cbIdx = this.cbs.findIndex(({ id }) =&gt; id === cbId)
        this.cbs.splice(cbIdx, 1)
        if(!this.cbs.length) this.stop()
    }
}
</code></pre>
<p>问题又来了，不同的回调函数，可能需要不同的计时间隔，这怎么处理呢？</p>
<p>可以通过一个计数器，计算 <code>setInterval</code> 的执行次数，执行次数 * 间隔时间就是执行总时间，有了执行总时间就好办了，只需要进行余运算即可。</p>
<p>首先设计 cb 对象数据结构</p>
<pre><code class="language-js">interface CallBack {
    id: number   // 回调 id
    cb: () =&gt; any   // 回调函数
    interval: number    // 执行回调间隔
}
</code></pre>
<p>再来实现demo:</p>
<pre><code class="language-js">// some code
let count = 0
let delay = 1000
let cbs: CallBack[] = []
 setInterval(() =&gt; {
    cbs.forEach(({ cb, interval }) =&gt; {
        if(!(count * delay % interval)) {
            cb()
        }
    })
}, delay)
// some code
</code></pre>
<p>上面 demo 中写死了执行间隔为 1000ms，那对于注册了 500ms 执行的回调函数来讲，会延迟 500ms 后才执行。我们可以遍历所有 cbs，从中获取最小的 interval 当做 delay 即可。</p>
<pre><code class="language-js">// some code
const min = this.cbs[0].interval
const delay = this.cbs.reduce((min, cur) =&gt;  cur.interval &lt; min ? cur.interval : min,min)
// some code
</code></pre>
<p>使用时，只需要 new 一个 Timer 实例，在需要倒计时的地方，通过 add 添加回调函数即可自动启动计时器，删除时，调用 remove 方法，删除完所有注册的回调函数，计时器自动停止。</p>
<pre><code class="language-js">const timer = new Timer()

const timerId = timer.add(() =&gt; {}, 1000)
timer.remove(timerId)
</code></pre>
<p>或者再改造一下，实现类似 <code>setTimeout</code> 和 <code>setInterval</code> 的调用方式</p>
<pre><code class="language-js">const setTimeoutInterval = timer.add.bind(timer)
const clearTimeoutInterval = timer.remove.bind(timer)
</code></pre>
<p>调用</p>
<pre><code class="language-js">const timer = setTimeoutInterval(() =&gt; {
    // some code
})

clearTimeoutInterval(timerId)
</code></pre>
<p>改造后的倒计时性能无疑好了许多，页面不再卡顿。且无论添加多少个计时回调，它运行的都是同一个计时实例。</p>
<p>使用 <code>setInterval</code> 的计时还是越来越不准，<code>setInterval</code> 会将回调函数间隔插入 JS 线程中，但线程如果正在执行耗时任务，插入的回调函数将偏移其应当在的位置，滞后执行，下一次插入的位置，参照了滞后插入的位置，所以导致运行时间越长，偏差越大。</p>
<p>使用递归 <code>setTimeout</code> ，不断修正将回调函数插入线程的时间，即可获得相对准确的倒计时。</p>
<p>简单实现一下:</p>
<pre><code class="language-js">let count = 0 // 递归次数
let now = Date.now() // 初始执行时间

function countdown() {
    const offset = Date.now() - (now + count * 1000)
    const nextTime = 1000 - offset
    count++

    setTimeout(() =&gt; {
        countdown()
    }, nextTime)
}
countdown()
</code></pre>
<p>这里我们记录了初始执行时间，和 countdown 递归执行的次数，根据这两者，我们可以计算出偏移时间和下次 <code>setTimeout</code> 的时间。</p>
<p>改造后，虽然倒计时准确了许多。但，又回到了上面的问题，列表项越多， <code>setTimeout</code> 实例越多，页面也会越来越卡。</p>
<p>我们可以使用 <code>setTimeout</code> 模拟 <code>setinterval</code>，并将其替换到上面我们的 <code>Timer</code> 类中。即可解决问题。</p>
<pre><code class="language-js">function timeoutInterval(cb, interval = 1000， getTimerId) {
  let count = 0
  let now = Date.now()
  let timerId = null

  function countdown() {
    const offset = Date.now() - (now + count * interval)
    const nextTime = interval - offset
    count++

    timerId = setTimeout(() =&gt; {
      countdown()
      cb()
    }, nextTime)

    getTimerId(timerId)
  }

  countdown()
}
</code></pre>
<p>这里值得注意的是，由于我们这里使用递归 <code>setTimeout</code>, 所以每次生成的 <code>timeId</code> 都是不一样的，所以设计将其通过 <code>cb</code> 回调函数的参数传出。</p>
<p>用法如下:</p>
<pre><code class="language-js">let i = 0
timeoutInterval(
    () =&gt; {
    // do something
    }, 
    1000,
    (timerId) =&gt; {
        this.timerId = timerId
    }
)
</code></pre>
<p>将其替换到 <code>Timer</code> 类中</p>
<pre><code class="language-js">class Timer {
    // some code
    private start() {
        timeoutInterval(
            () =&gt; {
                this.cbs.forEach(item =&gt; {
                    item.cb()
                })
            }, 
            1000,
            (timerId) =&gt; {
                this.timerId = timerId
            }
        )
    }
    // some code
}
</code></pre>
<p>改造后的倒计时性能良好，且因为只有一个计时实例，页面也不会卡顿。</p>
<p>具体实现请查阅代码: <a href="https://github.com/xdoer/TimeoutInterval">TimeoutInterval</a></p>
<p>对于一些秒杀抢购场景，这种倒计时是有问题的，因为本地时间与服务器时间有偏差，如果抢购单纯由前端倒计时来控制，那么很容易出现用户修改本机时间，页面就出现了购买按钮可以直接购买的 bug。由本地计时引起的 bug，在目前市面上的 APP 上很常见，除了抢购场景外，接口防重放机制中会校验客户端请求携带的时间戳，通常约定，如果客户端请求的时间戳与服务端时间偏差在 60s 之外，则该请求无效，所以在修改本机时间后，打开某些 APP，会看到空白页面。</p>
<p>解决办法很简单。</p>
<p>打开应用后，首先将客户端与服务端的偏移时间存到本地，秒杀倒计时的时候，将偏移时间加上即可。这样的话，无论客户端时间是提前还是之后，都对应用没有影响。</p>
<p>简单写个 demo:</p>
<pre><code class="language-js">// 首先获取偏移时间
prequest('/api').then(res =&gt; {
    // nginx 服务器，可以从响应头拿到时间
    const date = res.headers.Date
    const offsetTime = Date.now() - new Date(date).getTime()
    localStorage.setItem('offsetTime', offsetTime)
})

// 封装获取时间方法
function getNow() {
    const offset = localStorage.getItem('offsetTime')
    return Date.now() + Number.parseInt(offset)
}
</code></pre>
<p>回到我们的计时器代码，只需要将其中的 <code>Date.now()</code> 方法替换成 这里的 <code>getNow()</code> 即可。</p>
<p>到目前为止，上面的代码已经可以应对大部分计时场景，但对于秒杀场景来说，本地运行的倒计时可能还是不够可靠，可以设计间歇性请求接口获取服务端时间，更新倒计时，来获得更高计时精确度。</p>
<p>首先大致设计获取服务端时间方法</p>
<pre><code class="language-js">async function getServerTime() {
    const start = Date.now()    // 开启请求时间
    const serverTime = await prequest('/api').then(res =&gt; ...)
    const endTime = Date.now()
    return serverTime + (endTime - startTime) / 2
}
</code></pre>
<p>这里考虑了请求网络消耗的时间。</p>
<p>其次考虑我们倒计时，当每次拿到服务端时间后，加上 <code>interval</code> 时间，判断是否和目标时间相等即可。</p>
<p>demo如下</p>
<pre><code class="language-js">let now = Date.now()
let interval = 1000
setInterval(() =&gt; {
    if (now + interval &gt;=  endTime) {
        // some code
    }
}, interval)

setInterval(() =&gt; {
    getServerTime(res).then(res =&gt; now = res)
}, 5000)
</code></pre>
<p>这里我们维护了两个计时器，一个负责请求接口更新 <code>now</code> 数据，一个进行正常倒计时，写我们的业务逻辑。</p>
<p>当有 n 多个这样的倒计时实例，代码将不可维护。可以改造一下代码，使用类似事件发布订阅的模式来解决这个问题。</p>
<p>首先实现一个 <code>manager</code> 来实现事件发布订阅的逻辑</p>
<pre><code class="language-js">class CountDowmManager {
    queue = []
    tiemrId = null

    constructor({ getRemoteDate, interval }) {
        this.getRemoteDate = getRemoteDate
        this.interval = interval
    }

    private start() {
        this.timerId = timer.add(() =&gt; {
            this.getNow()
        }, this.interval)
    }

    private stop() {
        timer.remove(this.timerId)
    }

    on (countdown) {
        this.queue.push(countdown)
        if(!this.timerId) this.start()
    }

    off(countdown) {
        this.queue.splice(this.queue.findIndex(i =&gt; i === countdown), 1)
        if(!this.queue.length) this.stop()
    }

    private async getNow() {
        try {
            const start = Date.now()
            const nowStr = await this.opt.getRemoteDate()
            const end = Date.now()
            this.queue.forEach((instance) =&gt; (instance.now = new Date(nowStr).getTime() + (end - start) / 2)
        } catch (e) {
            console.log('fix time fail', e)
        }
    }
}
</code></pre>
<p>在 <code>CountDownManager</code> 类中，维护了一个 <code>countdown</code> 实例的队列，每隔 <code>interval</code> 个时间，会请求接口，更新所有实例的 <code>now</code> 值。同时设计将获取服务器时间的函数由参数传入，已满足不同场景的不同需求。</p>
<p>接着，设计倒计时</p>
<pre><code class="language-js">class CountDown {
    now = Date.now()
    timerId = null

   // ... some code
    start() {
        this.timerId = timer.add(() =&gt; {
            this.now += interval

            if (this.now &gt;= endTime) {
                // some code
                return
            }
        })
    }

    // some code
}
</code></pre>
<p>用法如下:</p>
<pre><code class="language-js">const manager = new CountDownManager()

const instance1 = new CountDown()
const instance2 = new CountDown()

manager.on(instance1) 
manager.on(instance2)
</code></pre>
<p>上面的 <code>CounDown</code>  代码中，只考虑了使用 <code>server</code> 更新时间的场景，其实我们也可以将上面使用本地时间进行的倒计时，整合到 <code>countDown</code> 类中。其次，可以设计将 <code>manager</code> 作为参数，传入到 <code>countdown</code> 实例，这样做的好处在于，我们不需要手动的注册和移除 <code>countdown</code> 实例，将 <code>managr</code> 当参数传入，在初始化实例时，就可以自动将当前实例注册；当倒计时结束，自动将当前实例移除。我们还可以根据是否传入 <code>manager</code> 来判断是否需要使用服务端来更新时间。</p>
<p>改造一下代码</p>
<pre><code class="language-js">class CountDown() {

    constructor({ manager, ...opt }) {
        this.manager = manager
        manager ? this.useServerToCountDown(...opt) : this.useLocalToCountDown(...opt)
    }

    useServerToCountDown() {
        // ...some code
        this.manager.on(this)
    }

    // ...some code

    clear() {
        timer.clear(this.timer)
        if(this.manager) {
            this.manager.off(this)
        }
    }
}
</code></pre>
<p>至此，我们就完成了一个高性能，好扩展，易用的计时器了。</p>
<p>完整代码请查阅 <a href="https://github.com/xdoer/CountDown">CountDown</a></p>
]]></content>
    </entry>
    <entry>
        <title type="html"><![CDATA[想到的一些很好的面试题]]></title>
        <id>https://aiyou.life/post/4XYWTWTx/</id>
        <link href="https://aiyou.life/post/4XYWTWTx/">
        </link>
        <updated>2021-05-30T07:32:50.000Z</updated>
        <summary type="html"><![CDATA[<p>写代码的时候，想到的和遇到的一些问题。想来可以做面试题。特记录于此。</p>
]]></summary>
        <content type="html"><![CDATA[<p>写代码的时候，想到的和遇到的一些问题。想来可以做面试题。特记录于此。</p>
<!-- more -->
<h2 id="对象">对象</h2>
<p>1、<code>{} === {}</code> 是否相等？<br>
2、<code>obj.a === obj.a</code> 是否相等?</p>
<pre><code class="language-ts">const obj = {
    a: {}
}
</code></pre>
<p>3、怎样使 <code>obj.a !== obj.a</code></p>
<pre><code class="language-ts">const obj = {
    get a() {
        return {}
    }
}
</code></pre>
<p>4、<code>obj.a === obj.a</code> 是否相等? <code>obj.a</code> 访问到的是哪一个？</p>
<pre><code class="language-ts">const obj = {
    a: {}
    get a() {
        return {}
    }
}
</code></pre>
<p>5、<code>getter</code> 访问与访问器访问有何区别？<br>
getter 可定义在实例和类上，值为一个无参数的函数，可以惰性赋值。</p>
<p>6、<code>getter</code> 访问与访问器存在相同属性的情况下，如何通过<code>getter</code>访问</p>
<pre><code class="language-ts">const obj = {
    a: {}
    get a() {
        return {}
    }
}
</code></pre>
<p>当在实例上定义 <code>getter</code> 访问，则 <code>getter</code> 存在于实例上。虽然可以定义同名属性，但却访问不到 。</p>
<p>使用 <code>delete obj.a</code> 将会都删掉。</p>
<p>可以考虑将其定义到原型上。</p>
<pre><code class="language-ts">class A {
    a = 1
    get a() {
        return 2
    }
}
const obj = new A()

obj.a === 1 // true
obj.__proto__a === 2 // true

delete obj.a

obj.a === 2 // true
</code></pre>
<p>7、<code>arr[0] === null</code> 是否相等？为什么?</p>
<pre><code class="language-ts">const obj = { a: 1 }
const arr = [obj]
obj = null
</code></pre>
<p>8、<code>arr.get(obj) === null</code> 是否相等？为什么？</p>
<pre><code class="language-ts">let obj = { a: 1 }
const map = new WeakMap()
map.set(obj, { a: 1 })
obj = null
</code></pre>
<p>9、a, b, c 的区别？如何在 class 写法上的原型上定义值？</p>
<pre><code class="language-ts">class A {
    a = 1

    constructor() {
        this.b = 2
    }

    c() {
        return 3
    }
}
</code></pre>
<p>a, b, c 的区别？如何在原型上定义值？</p>
]]></content>
    </entry>
</feed>