分类 前端 下的文章

原文链接:因卓诶-简单聊聊前端渲染模式以及Nuxt3.js

前言

最近的工作有涉及到ssr,所以这篇文章算是一个总结,并且对还在beta阶段的nuxt3做一个浅析。前段时间有一个蛮火的视频,关于rollup作者rich的一段演讲,在演讲里面rich梳理了ssr和csr,并且讲述了痛点,和提出新的概念“transition app”,如果你有兴趣可以看看这个视频

在文章开始前,我来简单介绍一下"spa", "mpa", "ssr", "csr"......这些个名词的意义。如果你是做web前端开发的,这几个词可能伴随着你的工作生涯很久很久了,相关文章互联网上多如牛毛,如果你对这些概念比较模糊甚至压根不知道,那么别关闭网页,我希望这篇文章能够拯救你。

SPA与MPA

MPA称之为“多页应用”, 那么什么是多页应用呢?字面意思其实就是有多个页面的应用就是多页应用。从技术手段上来讲,你可以这么粗略地理解。SPA,MPA不同点太多了,而且各有利弊。

MPA应用你需要单独维护多个html页面,而且我们每加载/切换一次页面,都需要加载一整个页面。但是它对于seo特别友好,因为我们可以给每一个html页面设置不同的meta等信息,从而达到更好的收录效果;所以MPA多出现在大型的电商/新闻网站等。

不同于MPA,SPA可以使得我们通过ajax或者其他技术动态的更改某一个区域的内容而不需要重新加载页面,包括切换页面也不会重新加载整个html,它对状态的留存做的很好,而且在移动端表现特别优异(因为在以前流量是很珍贵的,可以以最小的损失切换页面,无论是用户体验还是成本相较于MPA都是极大的改善)

SSR

在我们web较早的时候,开发者喜欢使用jsp或者其他模板渲染引擎来构造一个应用。我们一般称之为SSR(服务端渲染) 它的大致架构是如下这个样子

untitled.png

用户发起一个请求抵达后端服务器后:

  1. 后端会将用户所需要的内容通过数据层进行查询
  2. 处理业务
  3. 通过模板来拼接页面
  4. 返回一个html字符串给客户端
  5. 前端渲染然后加载js脚本完成剩余交互

你可能也发现了,在SSR服务端渲染中,前端负责的东西太过单薄,说得好听叫交互,难听点就是“点击事件工程师”。所以老一辈的后端基本人人都会前端,js的水平高的一抓一大把。随着使用SSR渲染页面的应用越来越多,弊端也出现了:

  1. 后端做了太多事情了,再牛逼的人也吃不消
  2. 前后端耦合,维护难度升级
  3. 内容更新/跳转,都需要重新加载一次页面
  4. 服务端渲染成本很高
  5. ...

CSR

CSR(客户端渲染)大致是以下的架构:

csr.png

CSR架构更贴近我们的现代前端开发,我们一般使用VUE, REACT这一类的前端视图框架时,都是默认CSR体系的。大致的流程是下面这样子的:

  1. 浏览器向前端服务器请求html和js,html页面是空html,并且同时执行js
  2. js渲染页面
  3. 通过后端暴露的api进行交互

SSR和CSR的区别

可以发现,使用CSR进行开发,会有几个明显的缺点

SEO

因为从前端服务器获取的html最开始是空html,这非常不利于seo,很多搜索引擎的老版本蜘蛛会直接爬页面,不会等待js加载完,所以会直接爬出来一个空页面。尽管现在的百度,谷歌等搜索引擎的爬虫能力很强,能够部分支持CSR SPA页面,SEO效果虽然可以其他方式弥补 (比如加入meta标签等等); 但是我们使用SSR完全不用担心,因为获得的html页面是一个完整的,可以直接渲染的。

用户体验(白屏)

关于白屏,由于CSR从HTML构建完成到JS渲染页面完成(但还没呈现页面)这一段过程中,是处于一个白屏的时间,用户体验很不好,反之使用SSR获得HTML之后只需要直接构建DOM就可以了。

同样的,我们使用SSR还有不一样的缺点:

  1. 成本问题(相比CSR多了构建HTML以及获取数据,需要更多的服务器负载均衡)
  2. 部署问题(与CSR部署环境不同,不是仅仅需要一个静态文件托管服务器那么简单了)
  3. 代码难度问题
  4. ...

使用Vite快速构建一个SSR(实践SSR)

Vite SSR虽然现在是一个实验性质,不能用于生产环境。但是我们可以使用Vite做一个ssr的demo,帮助我们理解SSR的构建,理解之后我们再来引入"Nuxt", "同构"等概念。Vite里面为SSR提供了很多支持,所以我们要开发一个demo,会非常非常简单,你也可以参考这篇官网文档

我们首先需要更改index.html的内容

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"><!--ssr-outlet--></div>
    <script type="module" src="/src/entry-client"></script>
  </body>
</html>

可以看到我们在app的div里写了一段注释,到时候我们渲染完之后的html将会replace这个注释。

然后需要在根目录新建一个server.mjs,作为我们的服务入口,用express作为一个例子:

// server.mjs
import { readFileSync } from 'fs'
import { resolve } from 'path'
import express from 'express'
import { createServer as createViteServer } from 'vite'

const createServer = async () => {
  const app = express()
  const vite = await createViteServer({
    server: { middlewareMode: 'ssr' }
  })
  // 使用vite这个中间件
  app.use(vite.middlewares)
  app.use('*', async (req, res) => {
    try {
      // 服务 index.html - 下面我们来处理这个问题
      const url = req.originalUrl
      // 读取根目录的模板
      let template = readFileSync(resolve('index.html'), 'utf-8')
      // 转换index.html 使其hmr有效
      template = await vite.transformIndexHtml(url, template)
      // 加载entry-server这个文件中的render方法
      const { render } = await vite.ssrLoadModule('./src/entry-server.js')
      // 根据url进行渲染
      const appHtml = await render(url)
      // 替换注释为准备好的html
      const html = template.replace(`<!--ssr-outlet-->`, appHtml)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (error) {
      vite.ssrFixStacktrace(e)
      console.error(e)
      res.status(500).end(e.message)
    }
  })

  app.listen(3000)
}

createServer()

我们的main.js也需要更改

// src/main.js

import App from './App.vue'
import Router from './router'
import { createSSRApp } from 'vue'

export function createApp() {
  const app = createSSRApp(App)
  app.use(Router)
  return { app, router: Router }
}

我们在main.js中,从vue导出createSSRApp函数,并且使用router,并且返回一个对象,这个对象之后将会被entry-server引用。

那么router也和我们传统的csr应用不太一样,我们根据env判断,传入了不同的路由类型:

// src/router/router.js

import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'

const Router = createRouter({
  history: import.meta.env.SSR ? createMemoryHistory() : createWebHistory(),
  routes: [
    {
      name: 'index',
      path: '/index',
      component: () => import('../pages/index.vue')
    }
  ]
})

export default Router

然后我们需要在src中新建 entry-client.js(会被index.html引入) 以及 entry-server.js

// src/entry-client.js

import { createApp } from './main'

const { app, router } = createApp()

router.isReady().then(() => {
  app.mount('#app')
})
// src/entry-server.js

import { createApp } from './main'
import { renderToString } from 'vue/server-renderer'

export const render = async (url) => {
  try {
    const { app, router } = createApp()
    // url跳转一下路径
    router.push(url)
    // 路由准备好
    await router.isReady()
    const ctx = {}
    // 返回一个html
    const html = await renderToString(app, ctx)
    return html
  } catch (error) {
    // console.log(error)
  }
}

到此为止我们可以在本地启动一个服务器,并且可以将我们的页面以ssr的形式渲染到浏览器中了,由于我们的demo代码都是esm,所以我们使用node执行,必须要写成mjs的后缀。

node server.mjs

启动服务器之后,访问/index这个路由,你就能看到我们的页面了

如果你的node版本不支持mjs,请先升级...

ssr示例项目:

  1. 本篇文章的demo
  2. 官方的demo

喝水,脱水,注水(SSR)

读到这里,你或许已经对ssr的流程有一个粗略的了解了;那么这一part的三个例子会加深你对ssr的理解,就是ssr常常说的喝水,脱水,注水

我们ssr在服务端构造页面时,数据是从数据源流下,使得我们页面数据得到填充,这个过程就叫做喝水(render & beforeRender)
喝水的过程就是在服务端渲染页面做的事情,就好比下面这个图:

1.png

饱满的水气球代表了一个健壮的网页

我们实现ssr需要直出html,所以需要把结构以及数据进行脱水 (如图)

fpic6008.jpeg

然后到了客户端,我们需要ssr应用重新焕活,就要让原本脱水了的state,prop等等数据恢复到原来的生机,并且重新render组件,这个过程就叫做注水

SSG

SSG这种渲染模式采取了CSR和SSR的共同优点,它不需要开发者介入服务器操作,开发者只需要准备cdn或者其他静态网页托管服务器,prerender出静态资源这一步将在构建时就已经做了,呈现在用户眼前的虽然不是实时变更的,但是也保留了CSR和SSR的精髓,一定程度上有了平衡。但是因为prerender的缘故,它和SSR的大致工作方式会相似一点。

也是有缺点的

  1. 随着业务的复杂,需要生成的页面可能不单单只有1,2个,所以这对于构建的要求很高
  2. 时效性问题,用户可能看到的页面是上一次生成的,所以这一部分仍需要其他模式来补充...

同构SSR和CSR(共享data)

同构说白了,就是将我们的前端代码,既能在客户端运行,也能在服务端运行,而且还能保持上下文的状态,我们在上面的改造例子已经实现了同一份代码在2个端的运行,但是并没有实现状态的同步,比如我们在nuxt中,使用asyncData这类钩子一样,能在服务端运行而且返回的data可以和客户端共享。

// 在nuxt2中我们可以这样
async asyncData({ store, $axios, $oss }) {
    return {
        hello: "world"
    }
}
<div>{{hello}}</div>

我们现在需要改造我们的demo:

// index.vue
// 新增一个option
asyncData() {
   return {
     hello: 'message'
   }
 }

其次在server端将asyncData返回的对象和其他页面html一起进行脱水:

// entry-
import { createApp } from './main'
import { renderToString } from 'vue/server-renderer'

export const render = async (url) => {
  try {
    const { app, router } = createApp()
    router.push(url)
    await router.isReady()
    let data = {}
    // 命中路由组件,且执行asyncData这个函数
    if (router.currentRoute.value.matched[0].components.default.asyncData) {
      const asyncFunc = router.currentRoute.value.matched[0].components.default.asyncData
      data = asyncFunc.call()
    }
    const html = await renderToString(app)
    return { html, data }
  } catch (error) {
    // console.log(error)
  }
}

// 我们的server.mjs也需要变更一下

// server.mjs

app.use('*', async (req, res) => {
    try {
      // 服务 index.html - 下面我们来处理这个问题
      const url = req.originalUrl
      let template = readFileSync(resolve('index.html'), 'utf-8')
      template = await vite.transformIndexHtml(url, template)
      const { render } = await vite.ssrLoadModule('./src/entry-server.js')
      const { html: appHtml, data } = await render(url)
      // 拼接标签,把data序列化插入到文档中
      const html = template.replace(`<!--ssr-outlet-->`, `${appHtml}<script>window.__data__=${JSON.stringify(data)}</script>`)
      res.status(200).set({ 'Content-Type': 'text/html' }).end(html)
    } catch (error) {
      vite.ssrFixStacktrace(e)
      console.error(e)
      res.status(500).end(e.message)
    }
  })

可以看到我们将data序列化到了window对象中了,接下来我们需要在client端注水的时候,把新data进行替换

// entry-client.js

router.isReady().then(() => {
  const component = router.currentRoute.value.matched[0].components.default
  let _data = {}
  // 判断是否是函数
  if (typeof component.data === 'function') {
    _data = component.data.call()
  }
  // 判断是否有脱水的data
  if (window.__data__) {
    _data = {
      ..._data,
      ...window.__data__
    }
  }
  component.data = () => _data
  app.mount('#app')
})

这个时候我们已经成功的看到index.vue中能够正确的在template中打印hello这个字段了

到这里,你就可以举一反三,使用vuex也可以进行同步数据,都是把data序列化到window中保存,然后在client挂载前重新commit到store里面就可以了。

Nuxt3

是时候引入nuxt了,我们如果使用nuxt将会更容易的完成ssr需求,这一部分不会教大家怎么写nuxt,毕竟都是框架,都很简单。我会和大家梳理一下nuxt2和nuxt3的变化,如果你用过nuxt2,那么这一部分内容你可能会非常感兴趣。写这篇文章的时候,nuxt3并没有release,所以到时候release后会考虑再出一篇总结。

值得关注的更新内容

  1. 更好的性能
  2. esm的支持
  3. vue3更好的集成,说明我们可以使用composition api了
  4. vite开发服务器加持
  5. webpack5 支持(尽管我不用)

Nitro Engine

简单翻阅了一下文档,和大家分享一下,在nuxt3中的新服务端引擎 Nitro Engine, nuxt2中服务端核心使用的是connect.js,而nuxt3使用的是nuxt团队自研的h3框架,特点就是具有很强的可移植性,而且非常轻量级,并且还支持connect编写的中间件。也就是说nuxt3基于h3编写的server端,可以无缝地移植到支持js运行环境的地方,比如说woker,serverless...

我们先试试,开发一个在nuxt3中使用的api

// server/api/hello.ts
export default (req, res) => {
  return 'Hello World'
}

同样,支持异步,也支持nodejs风格的调用

export default async (req, res) => {
  res.statusCode = 200
  res.end('hello world')
}

nuxt3也支持在同一个server文件夹中编写middleware,而且是自动导入的。nuxt3这次的更新,属于是把文件系统玩出花了,不光plugins不需要重复声明了(nuxt2要在config重复声明),而且components,composables(nuxt3新增的文件夹,可以存放公共hook)... 都可以支持自动导入。

试想一下,如今写nuxt3应用,搭配vue3 composition api,将会使开发体验上升好几个台阶。

文末,我们可以试试打包一个nuxt应用到cloudflare 作为woker运行是什么效果?我们在build之后会发现output文件夹很简洁(不像nuxt2迁移部署都很令人头疼)

我们不仅可以在最后的demo中看到页面,也可以访问 api/hello 这个路由查看刚刚我们在nuxt中定义的api

点击访问
部署到cloudflare-文档
demo地址

结语

又是水文一篇,希望以后可以出一些高质量的总结文章,希望这篇文章所讲述的前端常见的渲染模式,你能够知道,并且知道原理,这也就是本文最终的目标。框架会不会都没关系,我们要洞悉一切技术背后的真相,再去研究框架不是手到擒来么?

前言

我们在开发中可能会经常遇到以下几个事情:

  1. api接口文档描述不清
  2. 没有mock流程
  3. 如果使用ts开发,需要手动的描述类型,而且在前端中较难把api类型复用

社区中有大量的api文档工具,支持mock,单测的数不胜数。但是有生态,而且有开放api的开源文档工具其实寥寥无几。yapi是开源的国产文档管理工具,我们可以用yapi的开放api去做一系列拓展,在市面上就有许多浏览器插件/vscode插件,而且它支持私有部署。我们今天就使用我们之前文档提到过的工程架构模板,去构建一个todolist应用,我们的api则是使用midway和serverless快速开发的。

如果你还不太了解我们之前使用的“工程架构模板”,那你可以看一下这篇文章

YAPI

准备了4个api供我们测试,它们都是隶属于List模块,api的源码在这里,我们需要在yapi平台上把这些api进行登记。
我们新建了一个测试项目,并且新建了一个list分类,同时新增了4个api:

1. /v1/list get
2. /v1/list post
3. /v1/list delete
4. /v1/list put

WX20211021-213731.png

我们list模块类型声明如下:

export interface List {
  id: string;
  title: string;
  content: string;
}

export type AddItem = Omit<List, 'id'>;
export type UpdateItem = List;

比如说新增的api,在post方法下,我们需要传递title,content,返回一个新增成功的id,这是一个很常见的业务场景,我们在yapi定义一下。

1634827748418.jpg

这个时候就需要体现出社区的强大了,在社区中有类似yapi2typescript的浏览器插件,所以我就fork一份,再插件之上重新修改了interface名等&新增了controller,modle层代码片段,如果你需要安装这个插件,你可以到这个仓库查看源码

我们就可以在页面下面看到,如下的类型提示:

WX20211025-170349.png

然后我们就可以copy到我们的工程中进行使用,这个时候你可能会问,yapi社区里有很多to typescript的方案,比如说vscode或者命令行工具,为什么要使用浏览器插件?出于以下几点原因,我使用浏览器插件:

  1. 不依赖IDE,让团队能更好的统一
  2. 不过度依赖yapi,大多数生成方案强绑定yapi,通过api去和本地type diff,这样不容易去单独修改type,难以和临时变化的api协调。

我们通过vscode命令model-init-type 去生成一个list.d.ts文件内容, 然后把yapi的类型提示进行粘贴:

namespace TListApiModel {
  interface ReqAddList {
    /** 列表标题 */
    title: string;
    /** 列表内容 */
    content: string;
  }

  interface ResAddList {
    /** 数据id */
    id: string;
  }
}

export default TListApiModel;

然后我们就使用命令快速实现一下,list模块的model层和controller层:

// model api
import useRequest from '../../hook/useRequest';
import TListApiModel from '../../../typings/model/api/list';
export default class ListApiModel {
  addList(params: TListApiModel.ReqAddList): Promise<TListApiModel.ResAddList> {
    return useRequest({
      url: '/v1/list',
      method: 'POST',
      data: params
    });
  }
}
// controller
import TListApiModel from '../../typings/model/api/list';
import ListApiModel from '../model/api/list';
export default class ListController {
  private apiModel: ListApiModel;
  constructor() {
    this.apiModel = new ListApiModel();
  }
  addList(params: TListApiModel.ReqAddList) {
    return this.apiModel.addList(params);
  }
}

测试

enjoy模板新增了jest,我们可以直接使用jest对功能进行测试,如下:

// tests/model/api/list.test.ts

import ListController from '../../../src/controller/list';

describe('addList', () => {
  const controller = new ListController();
  it('添加参数测试-1', async () => {
    await controller.addList({
      title: 'test',
      content: 'nihao'
    });
  });
  it('添加参数测试-2', async () => {
    const result = await controller.addList({
      title: 'test2',
      content: 'hello?'
    });
    console.log(result.id);
  });
});

运行npm run test tests/model/api/list.test.ts

WX20211025-172728.png

文章源代码预览

vue3-vite2-ts-template

托管地址: github-vue3-vite2-ts-template

  • [x] 使用最新版本的 vite 和 vue3
  • [x] antdv 真正意义上的按需加载组件以及组件css
  • [x] git 提交前的 lint-stage+husky 校验和美化代码(prettier), 多人协作风格统一
  • [x] 开发预设 eslint 校验和自动修复以及 Editorconfig
  • [x] 自带开发常用依赖,antdv, axios, day, querystring...
  • [x] 适合中小项目的 typescipt 的 mvc 风格架构
  • [x] 工具方法贯彻 hook 风格,且预装 vueuse
  • [x] scss 基本工具库封装,页面和页面无需引入,直接使用预定义的全局变量/函数
  • [x] vite/rollup 打包优化
  • [x] storage,cookie TS版本的模块化方案
  • [x] 预设 Pinia 状态管理的模块化以及类型声明
  • [x] 预设开发环境的 vite-plugin-mock
  • [x] 预设自动装载路由 vite-plugin-pages
  • [ ] SSR/CSR 优化
  • [ ] 业务组件/type 类型文档自动生成,且在启动开发服务器时,自动打开 doc
  • [ ] 动画方案
  • [ ] 预装业务常用的 webcomponents 组件(团队自己开发组件库)

命令

命令含义
dev快速启动本地开发服务器
lint带有--fix 的 eslint 校验
prettiereslint-prettier 的美化代码命令
preparenpm install 自动执行的 husky 安装命令(不使用的请忽略)
lint-stage对 git 暂存区的文件进行操作,源于 lint-stage 插件
buildvite 内置的打包使用 rollup,此模板自带打包优化
serve本地预览生产环境的程序

启动/打包 命令

命令含义
dev:test快速启动本地开发服务器(test 环境)
dev:prod快速启动本地开发服务器(prod 环境)
build:test打包(test 环境)
build:prod打包(prod 环境)

技术栈:

  1. vue3
  2. vueRouter4
  3. pinia
  4. typescript

命令行

通过安装Tool,来可视化地使用模板,因为仓库中的模板大多数都不会全部用到,你可以通过tool去按需引入它们

npm i enjoy-project-tool -g

创建模板

enjoy create

当然,作为模板的伴生工具,我还会继续维护并且持续提出新的feature来减轻我们开发负担

Tool是使用TS开发的,如果你感兴趣可以提pr,这是Tool的仓库

类型文档/组件文档

文档待补充,暂定使用

  1. dumi作为组件库文档

代码提交

旧版本的husky和新版还是有很多不一样的,所以如果你以前用过husky那么你要在代码提交这里做更多逻辑的话,可以去看看最新的文档。

模板中只拦截了pre_commit这个钩子,目标就是在pre_commit的时候对代码进行lint和自动修复以及美化,而且仅要对暂存区的文件lint,所以使用了lint-staged。这个组合太常见了,有需求的开发者可以再这个上层定义一些有趣的功能提pr。

还有一个需求是校验git commit message的规范,但是对于小团队来讲,校验这个规范没有太大必要,也暂时不会对团队带来好处,所以爱鼓捣的可以去鼓捣哈。

可以推荐团队成员使用 git-commit-plugin-vscode

vscode 开发小指南

推荐使用 Volar 插件进行开发,如果你的 IDE 是 Jetbrains 系列的,那么你可能不太需要这个插件,如果你是 vscode 推荐使用 volar。使用 volar,不仅可以在 vue 开发上和
jetbrains 的表现一致,还可以得到更完善 vue3 的支持,甚至非常新/在草案的语法糖都能够快速享受到。

下载volar地址

此模板对于vscode有天然的支持,如果你使用vscode,就能使用模板自带的vscode配置,比如说保存自动lint&fix&prettier或者其他有意思的功能。

  1. 有那么一点智能的代码模板

模板中自带了若干个vscode的code-snippets,snippets将会持续更新,它和模板深度贴合,可以帮助你摆脱繁琐的开发。下面就一一描述几个snippets的作用:

  • model-init-type

    初始化@types/model/api的提示工具,自动声明命名空间以及导出

  • model-init-api

    初始化model下的api类,自动引入与之匹配的type类型声明文件以及其他可能用到的依赖

  • model-init-cache

    初始化model下的cache类,自动引入与之匹配的type类型声明文件以及其他可能用到的依赖

  • controller-init
初始化控制器类

  • vue-init

    初始化vue页面/组件

AntdV 开发小指南

传统的 antdv 的按需加载,都会使用 babel-plugin-import 这个插件进行按需分析然后自动引入,但是 antdv 中有很多嵌套的父子组件:

<a-menu>
  <a-menu-item></a-menu-item>
</a-menu>

由于内部设计原因,无法使用这个插件进行按需导入。最主要的是我们已经使用了vite,本身就带有按需导入,我们只需要处理他们的css的按需引入即可。所以使用了2个插件:

  1. vite-plugin-components
  2. vite-plugin-style-import

第一个插件主要帮助我们自动识别模板中用到的组件,实现自动引入,也就是说我们使用antdv这样的组件库的时候,不需要全量引入,甚至不需要手动的import就可以自动实现按需引入,如图:

而且脚手架内置了按需引入css的逻辑,所以antdv本身的设计原因导致引入css问题开发者也不需要担心。
第二个插件主要是辅助第一个插件做按需引入css逻辑的。第一个插件做的按需引入css有些许问题,比如说antdv里面有很多api调用的组件,比如message,通过message方法调用一个组件,这个时候css不生效,就需要使用第二个插件进行处理。

对于message这样的api组件的css不生效的原因很简单,第一个插件仅仅是解析template用到的组件然后自动引入css,但是无法处理import进来的api组件,所以需要第二个插件做处理。

开发指南

这一块根据自身团队成员的习惯会逐步调整,所以这里的介绍会经常更改。

这套微不足道的架构足以应对中小APP,也是非常简单的,主要就是mvc+ts风格。如果你阅读完整个模板文档之后,你会发现很多东西都做了模块化,把业务划分开了,这也是目前团队开发没有注意到的一点,自身开发完爽是爽了,另外一个人维护就要惨了。各种配置,api都找不到,组件/组件参数也找不到,可能为了快速开发,都会去复制老项目和其他页面的代码;这虽然也是一种“复用”,但是总归来说并不是标准的。所以只有将业务划分开,才能快速定位具体核心代码,才能快速复用。

类型

src/@types

像大部分工程一样,把能抽离的type都尽量都抽离到了@types这一层,这一层也暂时根据需求划分了以下几个内容:

  1. controller
  2. model
  3. hook
  4. store

里面最重度使用的应该是model,我们在model模型中根据业务定义了很多ts,比如user.ts:

namespace TUserApiModel {
  type ReqLogin = {
    captcha: string;
    password: string;
    username: string;
    uuid: string;
  };

  type ResLogin = Promise<
    ActionResult<{
      token: string;
    }>
  >;
}

export default TUserApiModel;

这两个就代表了model里面api层(后面会详细说明model里面的api),使用Req和Res作为前缀也就是请求和响应的类型,那么我们定义好之后,在整个工程中我就可以这样使用类型:

TUserModel.ReqLogin

那么同理,types文件夹中像store,hook这样的,也是根据业务划分,去定义类型的,这里就不再过多阐述了。

模型

src/model

目前model分为2个含义:

  1. api
  2. cache

前端大部分的数据来源都包含到了,api模型定义了不同业务的api方法,比如user.ts:

import useRequest from '../../hook/useRequest';

export default class UserApiModel {
  async login(params: TUserModel.ReqLogin): TUserModel.ResLogin {
    return await useRequest({
      url: `${params}`,
      method: 'get',
      options: {
        authApi: true
      }
    });
  }
}

useRequest是我们自定义实现的hook函数,我们通过这个hook可以发起请求,那么你可以看到在这个类中定义了login这个方法,入参类型就是TUserModel.ReqLogin, 返回类型就是TUserModel.ResLogin,这个类型都是我们在@types定义的。

再比如说我们搭配kurimudb做了缓存的模块化,最常用的缓存插件也预装好了,我们可以在model里面去写这样一段代码:

/model/cache/user.ts

import { Models } from 'kurimudb';
import { LocalStorageDriver } from 'kurimudb-driver-localstorage';
import { CookieDriver } from 'kurimudb-driver-cookie';

export class UserLocalStorage extends Models.keyValue {
  constructor() {
    super({
      name: 'user',
      driver: LocalStorageDriver
    });
  }
}

export class UserCookie extends Models.keyValue {
  constructor() {
    super({
      name: 'user',
      driver: CookieDriver
    });
  }
}

我们在这里定义了2个kurimudb类,一个是localstorage一个是cookie,我们可以在这里新增一些方法或者直接导出给controller用,因为即便你不新增方法也可以使用kurimudb内置的函数。

我们拥有kurimudb这样的库可以解决存储模块化的问题,我们不用关心这个缓存的key是否被使用过,只需要设置好唯一的name值,它就能给我们提供一组方便调用的api。另外kurimudb还有sessionstorage和indexDB的插件,如果业务需要可以快速的安装,然后声明一个新的类导出即可使用。

控制器

src/controller

在模板默认自带了一个user.ts例子,我们在上一个model中说明了apiModel和cacheModel,这里的controller就直接引入它们。并且在controller暴露入口。

import UserApiModel from '../model/api/user';
import { UserLocalStorage, UserCookie } from '../model/cache/user';

export default class UserController {
  private localStorageModel: UserLocalStorage;
  private cookieModel: UserCookie;
  private apiModel: UserApiModel;

  constructor() {
    this.apiModel = new UserApiModel();
    this.localStorageModel = new UserLocalStorage();
    this.cookieModel = new UserCookie();
  }
  async login(req: TUserModel.ReqLogin): TUserModel.ResLogin {
    return await this.apiModel.login(req);
  }
}

控制器我们还可以对api/cache获取的数据做处理,比如说,后端返回的数据格式前端不便直接展示,我们应该在controller需要做一层转译,比如像这样:

transform(): { text: string; value: string }[] {
    const data = {
      '0': '小明',
      '1': '小红'
    };
    let _arr = [];
    let key: keyof typeof data;
    for (key in data) {
      _arr.push({
        text: data[key],
        value: key
      });
    }
    return _arr;
  }

视图(.vue)

以vue来举例,我们如何在视图优雅的调用controller?并且如何使用@types定义的类型来巩固我们的组件?

import TUserApiModel from '../../@types/model/api/user';

const login = async (params: TUserModel.ReqLogin) => {
  await userController.login(params);
};

// 调用login函数
login({
  captcha: "",
  password: "",
  username: "",
  uuid: ""
})

当调用login函数时候,提供了与ReqLogin不符合的数据结构,是会出现报错的。同理,我们调用cache也是一样,需要在controller把cache封装一层暴露给vue即可。

环境变量

可以根据业务需要,建立业务相关的 env 环境(模式)。 vite-模式文档

以下是根目录默认提供了 3 个环境文件,对应了本地,测试,生产环境

  1. .env
  2. .env.dev
  3. .env.prod

内容示例: 根据业务需要进行配置

VITE_APP_API=
VITE_APP_SECRET=

那么同理,如果业务需要额外增加新的自定义环境变量,则需要在 src/vite-env.d.ts 中重新定义类型:

/// <reference types="vite/client" />
interface ImportMetaEnv {
  VITE_APP_API: string;
  VITE_APP_SECRET: string;
  // 新的环境变量的定义写这里
}

Mock

使用vite-plugin-mock来做本地开发的mock,模板暂时没有内置生产环境的mock。

// vite.config.ts
viteMockServe({
  localEnabled: true //是否开启本地的mock功能
}),

定义mock api:

// /mock/user.ts

import { MockMethod } from 'vite-plugin-mock';
export default [
  {
    url: '/api/get',
    method: 'get',
    response: (res: any) => {
      return {
        code: 0,
        data: {
          name: 'this is mock name'
        }
      };
    }
  }
] as MockMethod[];

其他的库

  1. dayjs
  2. axios
  3. vueuse
  4. kurimudb
  5. query-string

请输入图片描述

原文链接-因卓诶-人类高质量男性开发的基于TS且自带运行时校验的unicloud云函数是什么样子的?

前言

去年年底在上一家团队的伦哥和我提到了TypeScript的运行时校验,当时由于没有使用TS开发过后端,所以也就不太关心这个事情。但是今年开发TS后端,尤其是最近重构剑指题解开源项目的v
版本接口的时候,才觉得我必须要上运行时检测了。这篇文章内容不会很长(由于时间紧迫,我就简明扼要的写),我会从现有的云函数开始,然后和大家一步一步复盘改造细节。

改造开始

改造之前你需要了解为什么需要运行时检测?目前运行时检测有哪些方案,他们分别有什么弊端?
Nodejs开发中和我们客户端js开发都有共同问题,就是“undefined”,报各种定义找不到,而且后端规定API参数的时候很头疼。
往往要写这样繁琐的代码进行类型安全的控制:

// 添加文章
  async addArticle() {
    return handleMustRequireParam(
      [
        {
          key: 'title',
          value: '文章标题'
        },
        {
          key: 'content',
          value: 'content内容'
        },
        {
          key: 'tagID',
          value: '标签内容'
        },
        {
          key: 'desc',
          value: '描述'
        }
      ],
      this.event.params
    )
      .then(async () => await this.handler('addArticle'))
      .catch((err) => err);
  }

handleMustRequireParam这个方法是定义的一个函数,用于检测必传参数是否未传递或者为空。在每一个云函数中我们都要写这样一大段非常臃肿的代码。而且客户端鬼知道会给你传递什么乱七八糟的参数(这种情况在unicloud 文档云数据库经常出现)。所以swrod开源项目升级了explain.js到v2版本,加入了动态类型校验的中间件用来解决这个问题。

目前的运行检测方案你可以看一下这篇文章,ts运行时检测
看完之后,其实最大的弊端就是它不支持复杂类型校验,我如果想要写如下的代码,以前的方案就不支持我们这样做

interface Test{
  code: string;
  flag: string;
}

type IApiRes = Pick<Test, 'code' | 'flag'>

类似Pick, Partial这样高级的TS泛型在开发中经常用,但是现在很少有一个库能够解决这样的问题。
综合上述,我们使用了tsrpc框架中的核心库: tsbuffer-validator

这个库目前是没有文档的,这次复盘所有的代码都是看test测试用例推出来的,感谢作者大大持续帮我解决开发中各种问题

如果是经常关注我博客的水友都知道,我在前段时间写了一个垃圾的教程专门用来入门tsrpc框架,戳这里去看,tsrpc中最亮眼的ts运行时检测功能就是tsbuffer-validator这个库提供功能。

在浏览了这个库的test用例之后,我这样去开发:

1. 使用tsbuffer-proto-generator这个库将proto转换成schemas.json
2. 添加一个模块去做校验功能,模块中参数就是生成出来的schemas.json和当前触发api路由

首先我们定义proto,去写一个简单的ts代码

// proto/question.ts

export interface AddQuestion {
  title: string;
  areaID: string;
  content: string;
  tagID: string[];
}

我们定义了一个[添加题目]的api请求类型,而实际上我们在云函数路由中是question/addQuestion,我们到时候在写中间件的时候,要记得做一下第一个字母大写的处理,否则会找不到我们写的这个请求类型。

然后我们开始生成schemas.json

npm i tsbuffer-proto-generator --save-dev
这个模块建议安装到云函数之外,即你的工程最外面,不要安装在云函数内部

然后我们在云函数根目录去新建一个schemas文件夹

// schemas/genSchemas.js

const { TSBufferProtoGenerator } = require('tsbuffer-proto-generator');
const glob = require('glob');
const path = require('path');
const fs = require('fs');

async function main() {
  let generator = new TSBufferProtoGenerator({
    baseDir: path.resolve(__dirname, '..', 'proto')
  });

  let files = glob.sync('**/*.ts', {
    cwd: path.resolve(__dirname, '..', 'proto')
  });
  console.log('Files: ', files);

  let result = await generator.generate(files);

  fs.writeFileSync(path.resolve(__dirname, 'schemas.json'), JSON.stringify(result, null, 2));
}
main();

轮到我们的nodemon工具人登场,我们利用nodemon去监听proto文件夹协议的更改,从而触发genSchemas.js。

// nodemon.proto.json

{
  "watch": ["proto"],
  "ext": "ts",
  "exec": "node schemas/genSchemas.js",
  "legacyWatch": true
}

package.json -> script

"proto": "nodemon --config nodemon.proto.json",

运行npm run proto之后就可以看到schemas文件夹中有一个schemas.json

{
  "question/AddQuestion": {
    "type": "Interface",
    "properties": [
      {
        "id": 0,
        "name": "title",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 1,
        "name": "areaID",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 2,
        "name": "content",
        "type": {
          "type": "String"
        }
      },
      {
        "id": 3,
        "name": "tagID",
        "type": {
          "type": "Array",
          "elementType": {
            "type": "String"
          }
        }
      }
    ]
  }
}

细心的你可能会发现对象中有一个key为 “question/AddQuestion”,我们等会写中间件的时候,就拿api的url去匹配这个key从而拿到api的schema

写一个名为tsbuffer-params-validate的中间件

const { TSBufferValidator } = require('tsbuffer-validator');

module.exports = function (event) {
  // schemas 由ts的proto生成的schemas
  // params 参数
  // service 路由url中的service
  // action 具体的方法
  const { schemas, params, service, action } = event;
  const validator = new TSBufferValidator(schemas);
  let vRes = validator.validate(params, `${service}/${action.replace(/^\S/, (s) => s.toUpperCase())}`);
  return vRes;
};

然后中间件会返回给我们结果:这个参数是否校验成功(如果出错误,会返回具体错误信息),然后我们就去云函数中去使用这个中间件。

    // 添加校验参数中间件
      app.use(async ({ event }) => {
        const validateResult = await ParamsValidate({
          ...event,
          params: event.data,
          schemas
        });
        if (!validateResult.isSucc) {
          // 将响应信息改为异常信息
          explain.response.body = {
            message: validateResult.error
          };
        }
      });

到此为止我们都已经改造好了,我们可以测试一下访问云函数具体路由的时候,会不会去校验参数:

"data": {
    "title": "",
    "areaID": "",
    "content": "",
    "tagID": [1]
},

如果我们传递一个这样的参数,中间件就会拦截,并且爆出这样的错误信息:

{
    "isSucc": false,
    "error": {
        "type": "TypeError",
        "params": ["string", "number"],
        "inner": {
            "property": ["tagID", "0"],
            "value": 1,
            "schema": {
                "type": "String"
            }
        }
    }
}

提示我们的tagID需要是string[],而不是number[]。

结束

这篇文章内容很简单,由于很多库没有文档,我还是花了一部分时间去推敲研究的。而且用tsrpc核心库的人太少了,这么好的东西必须要分享给大家,而且在unicloud领域,使用ts开发的人本来就是稀有,所以这篇文章的种种技巧都能实打实的解决各位云函数开发者的大问题,希望大家能用到工程上,用完就懂运行时检测类型有多香了。

扫码_搜索联合传播样式-白色版.png

因卓诶原文链接

一个虚假的故事(狗头)

设想一下,如果10人以下的前端小团队要合作去协同一个项目;

这个时候leader说: "小明, 我们这次使用vue3吧,你去搭建一个项目吧!"

小明第一次开发vue3的项目,但是身为老司机却丝毫不慌,它按照官方文档一步一步的构建脚手架,去添加各种依赖,然后把之前vue2或者其他项目的公用代码复制过来。

1天过去了...

小红拿到了最新的脚手架代码,打开自己的webstorm;小蓝打开了自己的sublime;小紫打开了自己的vscode。数周之后,leader查看仓库,发现小红,小紫,小蓝的代码/架构风格迥异,而且代码毫无章法的蜷缩在仓库中,甚至出现了很多组件/公用方法重复的问题。几周之后慢慢迭代,慢慢地成为了“屎山”。当团队的人越来越多,这个代码可能就越“屎”。


前言

我从一毕业从事工作开始到今天,小团队接触的特别多,这种情况其实非常常见。但是由于时间和其他原因,一直没有机会把我开源项目的那一套技术栈搬过来;上周终于不是很忙就筹备了二个项目,用来解决小型团队的常见问题,成本不高,只需要一个包。在开始介绍之前,我们罗列一下问题列表:

  1. 开发工具不统一
  2. 代码规范以及美观性
  3. 初始化一个项目做的准备工作太多
  4. 技术迭代不够快

我们今天这篇文章主要讲述2个项目:

enjoy-project-tool仓库
enjoy-project-template仓库

命令行工具主要的作用就是拉取我们的代码模板,这个大家见怪不怪了,但是我仍需要描述一些细节给第一次做命令行的兄弟们看。至于项目模板,我开源的仓库的readme已经做了很多很多介绍以及使用方式,这里就不阐述过多了。

可以一边看完整代码,一边code

准备

使用nodejs开发命令行工具是对前端工程师最友好的方式,我们可以直接通过node去执行我们的js文件,所以我们随便npm init一个新的工程,然后更改我们的package.json

{
  "name": "enjoy-project-tool",
  "version": "1.0.0",
  "description": "",
  "main": "dist/main.js",
  "scripts": {
    "run": "nodemon",
    "lint": "eslint 'src/**/*' --fix",
    "prettier": "prettier --write '**/*.{ts,js,json,markdown}'",
    "lint-staged": "lint-staged",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "lint-staged": {
    "*.{ts,js,json,markdown}": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  },
  "bin": {
    "enjoy": "dist/main.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "chalk": "^4.1.2",
    "commander": "^8.1.0",
    "download-git-repo": "^3.0.2",
    "inquirer": "^8.1.2",
    "ora": "^5.4.1"
  },
  "devDependencies": {
    "@types/inquirer": "^7.3.3",
    "@types/node": "^16.7.10",
    "@typescript-eslint/eslint-plugin": "^4.30.0",
    "@typescript-eslint/parser": "^4.30.0",
    "esbuild-node-tsc": "^1.6.1",
    "eslint": "^7.32.0",
    "eslint-config-prettier": "^8.3.0",
    "eslint-plugin-prettier": "^4.0.0",
    "husky": "^7.0.2",
    "lint-staged": "^11.1.2",
    "nodemon": "^2.0.12",
    "prettier": "^2.3.2",
    "typescript": "^4.4.2"
  }
}

给大家描述一下,我们用什么技术栈去开发这个命令行工具?

首先我们需要typescript,使用esbuild-node-tsc(简称ETSC)去编译我们的ts代码;
其次我们使用husky和lint-staged以及eslint去对我们的代码做git提交之前的校验;
prettier主要做一个代码美化;
使用nodemon便于我们开发调试自动编译

如果你对上面的技术栈不是特别了解,没关系。我们再讲述具体实现的时候还会提到它们的。

开发命令行工具我们需要给node暴露一个js文件,那么这个文件我们可以存储dist目录下,并且我们需要描述一个名称,让node环境通过名称去找到对应的js文件并且运行,所以我们就在package.json中写下了这一行代码:

 "bin": {
    "enjoy": "dist/main.js"
  },

那么到时候我们可以使用如下的命令访问到我们的程序:

enjoy

我们只需要把我们写好的ts代码去编译到dist目录下就大功告成了~

hello world

纯属个人习惯,我把main.ts文件放到了src目录之下,然后我写下了下面的代码:

#!/usr/bin/env node

console.log("hello world")
#!/usr/bin/env node 必须加入这段代码,让代码使用node进行执行

这个时候我们需要配置一下ts和etsc:

根目录新建 tsconfig.json

{
  "compilerOptions": {
    "target": "es6",
    "module": "CommonJS",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "moduleResolution": "Node",
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true,
    "forceConsistentCasingInFileNames": true,
    "typeRoots": ["./node_modules/@types", "./typings"]
  },
  "include": ["./src/*.ts"]
}

这个配置文件我们需要关心include,以及target,module,outdir,typeRoots;

include: 包含ts的目录
target: 我们编写的ts代码的版本,es6/es5...
module: 我们编译之后的模块类型,ems还是cjs,这里我们选择的是cjs
outDir: 输出js的目录
typeRoots: 类型声明文件存放的目录,我们一会需要在typings这个目录下去定义我们的类型

typescript自带了tsc用于编译ts文件,但是我们使用了etsc只因为它速度更快,etsc默认是开箱即用的,只用你根目录下的tsconfig.json配置它就能工作。但是我们仍需要它把编译之后的代码进行压缩,所以我们需要对etsc进行额外的配置,我们需要新建etsc.config.js

module.exports = {
  esbuild: {
    minify: true
  }
};

这个时候我们项目根目录运行etsc的命令,就可以看到根目录出现了dist文件夹并且其中有main.js的文件。我们就可以用nodejs去运行它了。

我们在命令行输入:

npm link

> npm link 命令可以把你写的工程 链接 到你电脑的全局环境,方便调试,而且不需要install

这个时候我们在命令行输入: enjoy

就可以在控制台显示出我们写的hello world了

优化开发体验

我们已经成功的打印出了hello world,我们每一次编辑main.ts文件都需要运行etsc命令,这就不是昊哥干的事情。所以我们用nodemon去监听文件的编辑,然后让nodemon去触发etsc的命令。我们需要在根目录新建一个nodemon.json:

{
  "watch": ["src/*"],
  "ext": "ts",
  "exec": "etsc",
  "legacyWatch": true
}

然后在package.json中我们已经写入了脚本命令:

"run": "nodemon",

执行

npm run run

这个时候nodemon服务已经启动了,我们更改main.ts,nodemon会自动去执行etsc命令,直接就把文件编译到dist里面了,然后我们就可以无缝的去在命令行调试最新的结果(npm link之后就不需要再次执行这个npm link了)

核心代码开发

commander这个库是用于nodejs开发命令行的最佳工具,我们可以使用这个包去完成很多事情,比如命令的控制等,我们可以在main.ts中写入以下代码:

#!/usr/bin/env node

// 命令行
import { program } from 'commander';

program
  .command('create')
  .description('create template (创建模板)')
  .action(async () => {
    // 回调函数
    console.log("回调函数")
  });

program.parse(process.argv);

program也可以支持命令行的version版本,这一块我就不描述了,具体可以查阅文档。

运行enjoy就能看到如下内容:

1630837375700.jpg

commander帮助我们把一些必要的生成完毕了,我们可以直接运用enjoy create就可以把我们刚刚注册的create的回调函数触发。

我们目前这个命令行工具支持创建模板的功能,所以我有必要把create这个操作抽离出去,所以我新建了一个ts文件专门处理create的逻辑。

然后代码改写:

program
  .command('create')
  .description('create template (创建模板)')
  .action(async () => {
    await import('./create.js');
  });

program.parse(process.argv);

我们新建的create.ts内容如下

#!/usr/bin/env node

import inquirer from 'inquirer';
import ora from 'ora';
import fs from 'fs';
import { exec } from 'child_process';
import download from 'download-git-repo';
import chalk from 'chalk';

const spinner = ora('下载模板中, 请稍后...');

// 模板字典
const template: { name: string; value: string }[] = [
  {
    name: 'vue3-vite2-ts-template (ant-design-vue)模板文档: https://github.com/seho-code-life/project_template/tree/vue3-vite2-ts-template(release)',
    value: 'seho-code-life/project_template#vue3-vite2-ts-template(release)'
  },
  {
    name: 'node-command-ts-template                 模板文档: https://github.com/seho-code-life/project_template/tree/node-command-cli',
    value: 'seho-code-life/project_template#node-command-cli'
  }
];

// 安装项目依赖
const install = (params: { projectName: string }) => {
  const { projectName } = params;
  spinner.text = '正在安装依赖,如果您的网络情况较差,这可能是一杯茶的功夫';
  // 执行install
  exec(`cd ${projectName} && npm i`, (error, stdout, stderr) => {
    if (error) {
      console.error(`exec error: ${error}`);
      return;
    } else if (stdout) {
      spinner.text = `安装成功, 进入${projectName}开始撸码~`;
      spinner.succeed();
    } else {
      spinner.text = `自动安装失败, 请查看错误,且之后自行安装依赖~`;
      spinner.fail();
      console.error(stderr);
    }
  });
};

// 修改下载好的模板package.json
const editPackageInfo = (params: { projectName: string }) => {
  const { projectName } = params;
  // 获取项目路径
  const path = `${process.cwd()}/${projectName}`;
  // 读取项目中的packagejson文件
  fs.readFile(`${path}/package.json`, (err, data) => {
    if (err) throw err;
    // 获取json数据并修改项目名称和版本号
    const _data = JSON.parse(data.toString());
    // 修改package的name名称
    _data.name = projectName;
    const str = JSON.stringify(_data, null, 4);
    // 写入文件
    fs.writeFile(`${path}/package.json`, str, function (err) {
      if (err) throw err;
    });
    spinner.text = `下载完成, 正在自动安装项目依赖...`;
    install({ projectName });
  });
};

// 下载模板
const downloadTemplate = (params: { repository: string; projectName: string }) => {
  const { repository, projectName } = params;
  download(repository, projectName, (err) => {
    if (!err) {
      editPackageInfo({ projectName });
    } else {
      console.log(err);
      spinner.stop(); // 停止
      console.log(chalk.red('拉取模板出现未知错误'));
    }
  });
};

// 定义问题列表
const questions = [
  {
    type: 'input',
    name: 'projectName',
    message: '项目文件夹名称:',
    validate(val?: string) {
      if (!val) {
        // 验证一下输入是否正确
        return '请输入文件名';
      }
      if (fs.existsSync(val)) {
        // 判断文件是否存在
        return '文件已存在';
      } else {
        return true;
      }
    }
  },
  {
    type: 'list',
    name: 'template',
    choices: template,
    message: '请选择要拉取的模板'
  }
];

inquirer.prompt(questions).then((answers) => {
  // 获取答案
  const { template: templateUrl, projectName } = answers;
  spinner.start();
  spinner.color = 'green';
  // 开始下载模板
  downloadTemplate({
    repository: templateUrl,
    projectName
  });
});

inquirer这个工具可以帮助我们引导用户在命令行做出“输入/选择等”的操作,但是前提我们定义了一个数组,这个数组就是questions,模板内容存储到了一个公开的仓库(不同类型的模板,分为不同的分支),我们通过download-git-repo这个包去进行下载。模板通过这个repo这个包下载完毕之后,我们调用editPackageInfo去更改packagejson中的信息(比如项目名称);之后我们就可以自动帮助用户去下载依赖,install方法主要就是通过node去运行安装的命令,这里非常简单,就不做过多描述。

而这里的chalk依赖主要是帮助我们去做命令行上的一些色彩显示,比如警告,错误,成功,我们都可以去打印出好看好玩的颜色,让整个命令行的ui变得高大上。
ora这个依赖则帮助我们去做了loading,以及关键文案的提示。

这样我们就完成了一个带有拉取模板功能的命令行工具,我们再次运行create命令,就能正常运行了。

上传之前的准备

在文章的开始我们提到了一个小故事,并且总结了几个开发的问题,我们开发完毕的命令行工具仅仅解决了初始化项目时间成本的问题,但是剩余的三个问题一个也没得到解决,而这一part才是真正实用的东西,在开始我们安装了非常多的依赖,比如eslint以及husky,lint-staged等等,我们这一part主要就是使用这几个依赖打造一个标准的git提交流程,让多人协作变得稳重和统一。

而我们刚刚使用的命令行工具,其中拉取的模板,也同样是这样的校验流程。

我们先从大家最熟悉的eslint开始配置,eslint的配置其实每个团队有自己的方案,但是大多数默认还是用eslint官方的默认配置,所以我这就不做过多花里胡哨的配置。

.eslintrc.js

module.exports = {
  root: true,
  env: { browser: true, node: true },
  parser: '@typescript-eslint/parser',
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module'
  },
  plugins: ['@typescript-eslint'],
  extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'],
  rules: {
    // 支持ts-ignore
    '@typescript-eslint/ban-ts-ignore': 'off',
    '@typescript-eslint/no-var-requires': 0
  }
};

这是一个相对标准的ts工程的eslint,非常适合我们命令行项目,所以我就直接拿过来用了。

这个工程我们需要注意,我们在之后会用到prettier这个美化插件,我们这边配置的extends中有 plugin:prettier/recommended ,因为可能eslint和prettier有冲突,我们这里以prettier为优先,所以不要动它们两个插件的引用顺序。

.eslintignore

node_modules
dist

在现有的package.json中我们也写入了一个命令

"lint": "eslint 'src/**/*' --fix",

这个时候我们尝试一下运行命令,它就能帮助我们自动校验以及fix修复。

除此之外我们在package.json中还定义了一个prettier命令,我们直接运行,就可以发现所有的代码都被美化了格式了。

我们同样可以定义prettier的配置文件以及ignore

.prettierrc

{
  "singleQuote": true,
  "trailingComma": "none",
  "printWidth": 160,
  "tabWidth": 2,
  "tabs": false,
  "semi": true,
  "quoteProps": "as-needed",
  "bracketSpacing": true,
  "jsxBracketSameLine": true,
  "htmlWhitespaceSensitivity": "ignore",
  "useTabs": false,
  "jsxSingleQuote": false,
  "arrowParens": "always",
  "rangeStart": 0,
  "proseWrap": "always",
  "endOfLine": "lf"
}

.prettierignore

dist/
node_modules
*.log

这个时候你可能会问,不仅仅是代码,我们团队不同的ide,空格和tab等等都有差异这该怎么办? 我们可以在根目录定义

.editorconfig

# Editor configuration, see http://editorconfig.org

# 表示是最顶层的 EditorConfig 配置文件
root = true

[*] # 表示所有文件适用
charset = utf-8 # 设置文件字符集为 utf-8
indent_style = space # 缩进风格(tab | space)
indent_size = 2 # 缩进大小
end_of_line = lf # 控制换行类型(lf | cr | crlf)
trim_trailing_whitespace = true # 去除行首的任意空白字符
insert_final_newline = true # 始终在文件末尾插入一个新行

[*.md] # 表示仅 md 文件适用以下规则
max_line_length = off
trim_trailing_whitespace = false

我们可以通过这个小小文件来约束每一个编辑器编写出来的代码特征,甚至在个别ide,我们可以去做自定义的配置,比如说vscode,我可以在根目录新建

.vscode/settings.json

{
  "npm-scripts.showStartNotification": false,
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "typescript.preferences.importModuleSpecifier": "relative",
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

这个配置完成之后,我们可以用到里面一个很实用的功能,就是保存自动eslint fix修复,我们再回到vscode里面,就不用每次保存都需要手动格式化代码啦。

我们现在已经完成了代码统一和美化,以及ide的统一的工作了,现在应该要着手git了,我们在项目提交git之前,需要做一次lint+prettier确保远程仓库的代码统一标准。

那么如何去在提交之前去做校验逻辑呢?husky就可以帮助我们做到这些,我们尝试运行这个命令:

1. husky install
2. npx husky add .husky/pre-commit "npm run lint-staged"

执行完毕之后会在根目录出现一个husky文件夹,其中有一个pre-commit的文件,在这个shell中我们可以看到npm run lint-staged的命令

而在文章开始的package.json中就已经定义好了这一段代码:

 "lint-staged": {
    "*.{ts,js,json,markdown}": [
      "prettier --write",
      "eslint --fix",
      "git add"
    ]
  },

那么lint-staged是什么呢?我们在工程中,通过husky拦截到了git的commit事件,这个时候如果进行一次lint和美化那么是全局意义上的操作,这是非常耗费时间和性能的,所以我们只需要对git的暂存区的文件做lint就可以了,而lint-staged就可以帮助我们去对暂存区的文件进行一系列操作的工具。我们在package.json中就定义了如果暂存区的文件是ts,js,json,markdown的话就要依次执行下面的三个命令。

那么到这里为止,我们的代码就开发完毕了,我们已经成功的完成了自动校验,以及拦截,美化,ide统一/加强。我们开发的命令行工具虽然只有一个拉取模板的小功能但是确实解决了大部分团队的痛点问题,文章开头提到的问题也一一得到了解决。但是我觉得还不够,要在上一点好玩的东西....

GithubAction自动发布到NPM

我们开发出来的命令行工具势必要发布在npm中的,或者是你们团队npm私库都是要易于管理。所以我们将这个工程新建一个dev分支,我们在dev上面开发,当合并到master之后,就自动触发一个action,帮助我们自动发布...

但是在准备之前我们需要在工程的根目录新建

.npmignore

src/
etsc.config.js
tsconfig.json
nodemon.json
typeings/
.*

我们把相关ts,开发文件进行屏蔽。

接着我们去写workflows (新建)

.github/workflows/npm-publish.yml

name: npm-publish
on:
  push:
    branches:
      - master # Change this to your default branch
jobs:
  npm-publish:
    name: npm-publish
    runs-on: ubuntu-latest
    steps:
      - name: 切换仓库
        uses: actions/checkout@master
      - name: 准备Node环境
        uses: actions/setup-node@master
        with:
          node-version: 12.13.0
      - name: 安装依赖以及编译
        run: npm i && npx etsc
      - name: 推送到NPM
        uses: JS-DevTools/npm-publish@v1
        with:
          token: ${{ secrets.NPM_AUTH_TOKEN }}

我们推送到NPM用到了JS-DevTools/npm-publish@v1这个action,这个action好像是最火的推送npm的action,使用方式也非常的简单。我们只需要把本地的package.json的version管理好就可以了,然后我们需要去npm官网,去新建一个token:

WX20210905-195041.png

生成之后的token我们拿到存此工程的仓库去设置:

WX20210905-195236.png

生成的token名称就是: NPM_AUTH_TOKEN

这个时候我们把代码提交到master主分支,就可以发现action已经在运行了,稍等片刻就已经构建完成了。然后npm的包也发布了~

WX20210905-195458.png

enjoy-project-tool npm包

结束

这篇文章说实话算水文了,用到的技术也很简单,希望如果团队还没有这种东西的话就自己着手尝试一下,这个东西很简单,顶多一个早晨的时间就写完了,但是能换来团队很大的便利,这就很值得了。抛砖引玉,我估计有很多用在线的校验平台,其实道理都差不多,我们今天写的这个算是丐版(但是我依然觉得做本地的校验非常有必要)。我把tool和对应的项目模板开源了,会一直维护这个项目。不管是公司用还是自己用,都是非常方便(嘻嘻)

关注公众号老铁们,求你们了~~