分类 前端 下的文章

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和对应的项目模板开源了,会一直维护这个项目。不管是公司用还是自己用,都是非常方便(嘻嘻)

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

前言

某个普通的一天的早晨,水友群的小姐姐和我聊前端架构,因为她们组最近要筹备一些新项目,在做架构的中途出现了很多问题,所以我拿到了她们的架构项目脚手架代码。拿到代码之后我发现深圳那边的前端团队普遍做的很好,有先进的架构思想,也把ts用的很纯粹,最后没帮人家解决问题,反倒是自己学到了不少。最后我们聊到了前后端全栈开发,如何动态校验协议参数等问题,因为熟悉我开源项目(剑指题解)的朋友都知道,我的后端代码尤其是动态校验那块写的是真差,为了ts而用ts,这也是目前很多用ts的小伙伴的通病,所以我一直打算重构我的一部分后端代码,这个时候见多识广的小姐姐就推荐给我了一个框架,这个框架也是[see how]系列第一篇教程的主角,这个框架就叫做TSRPC

关于专栏

关于see how是什么,说来很巧,这也是TSRPC作者王大大对我的seho这个名字的猜测,其实我的一个名字也没那么多深意,然后被大佬解读成了see how,所以我感觉这是一个不错的idea,那么本来就是想要出一个tsrpc的系列教程,和大家一起学习这个优秀的框架,就正好作为see how 专栏的第一篇文章吧。

关于TSRPC

在正文开始之前,我希望大家可以去自行先去简单快速的浏览相关知识,tsrpc是一个ts的开源rpc框架,它是为了全栈项目而生的,从我上手的第一天开始,我就对这个框架有了以下的第一印象:

  1. 天然二进制传输
  2. 纯粹的ts,规避了极大部分开发中的错误
  3. 强大的运行时复杂检测
  4. 这种前后端开发模式,我闻所未闻

官方文档
视频教程

前期准备

学习tsrpc需要你有一些前置知识和其他准备:

  1. 熟悉typescript基本语法
  2. 准备一个mongodb数据库

开发

使用tsrpc开发全栈应用简单到没朋友,可以从官方提供的cli快速创建前后端一体项目:

npx create-tsrpc-app@latest

按照指引选择浏览器应用,等待完成安装之后,你的目录中会出现2个目录:

- backend 后端
- frontend 前端

我们直接一睹为快,在前端项目根目录运行

npm run dev

官方的脚手架为我们准备了一个简单的todolist应用

WX20210706-072702@2x.png

整个前后端的目录结构(摘抄官网)

|- backend --------------------------- 后端项目
    |- src
        |- shared -------------------- 前后端共享代码(同步至前端)
            |- protocols ------------- 协议定义
        |- api ----------------------- API 实现
        index.ts

|- frontend -------------------------- 前端项目
    |- src
        |- shared -------------------- 前后端共享代码(只读)
            |- protocols
        |- index.ts

诶,你可能会疑问了,为啥会有一个莫名其妙的shared目录,还要给前端项目去分享这个目录。是因为在shared这个目录我们要定义协议,啥玩意是协议呢?我们通过一个小小的接口来给大家解释什么是协议;

// PtlAddPost.ts

export interface ReqAddPost {
  newPost: {
     name: string;
  };
}

export interface ResAddPost {
  insertedId: string;
}

我们可以在shared/protocols中新建了一个文件PtlAddPost.ts,我们必须以Ptl进行开头定义协议,协议是用来描述一个接口的请求和响应的结构体的文件,你可以这么理解。协议文件通过shared目录共享到前端,你知道会发生什么事情吗?造成了我们前端在对接口的时候,全程代码提示以及严格和请求和返回类型校验。

那么我们接着后端继续聊,协议定义之后该如何做呢?

npm run proto 每当协议更改后,需要重新运行这个命令

tsrpc的设计是协议和api分离,我们必须要清楚,api在我的认知里就是一个异步函数,tsrpc可以帮助我们根据我们刚刚写的协议生成api,比如刚刚我们实现的PtlAddPost.ts,我们运行

npm run api 新协议生成一个新的api

在api目录中会多出一个ApiAddPost.ts

import { ApiCall } from "tsrpc";
import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  
}

我们通过call这个方法获取请求参数以及响应给客户端一些信息,我们来一个简单的例子:

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  if(call.req.newpost.name){
     call.success({
         msg: "hello," + call.req.newpost.name
     })
  }else{
     call.error('Invalid name');
  }
}

我们的第一个api已经写完了,我们需要正常的过一次test,然后我们在让前端去调用。

tsrpc使用的是mocha这个测试框架。

// /test/api/**.test.ts

import { HttpClient } from "tsrpc";
import { serviceProto } from "../../src/shared/protocols/serviceProto";

// 1. EXECUTE `npm run dev` TO START A LOCAL DEV SERVER
// 2. EXECUTE `npm test` TO START UNIT TEST

describe("api 测试", async function () {
  let client = new HttpClient(serviceProto, {
    server: "http://127.0.0.1:3000",
    logger: console,
  });
  let ret = await client.callApi("AddTest", {
    newPost: {
      name: "seho"
    },
  });
});

这是我们后端的一个简单的测试用例,在运行这个测试用例之前,您必须要开启后端的服务:

npm run dev

然后可以再开启一个窗口运行npm run test,如果一切正常,你可以看到下面的控制台输出:

1625806333932.jpg

粗略计算了一下,我们从开始定义协议到api测试完成,一个简单的接口不到5分钟就已经完成。

这个时候我们可以把这个接口放到前端再继续测试一下。

当然在此之前,我们需要运行以下命令:

npm run sync

我们之前提到过,前后端有一个共享的目录,运行此命令我们就可以把协议等信息同步过来,这个时候我们可以在前端的index.ts文件中,可以获得非常完善的代码提示。

// frontend/src/index.ts

import { HttpClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

// This is a demo code file
// Feel free to modify or clear it

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  logger: console,
});

client.callApi("AddTest", {
  newPost: {
    name: "hello, seho"
  },
});

当我们回到浏览器前端页面上时,这个请求就会发出,如果你仔细观察控制台,会看到以下的场景:

1626042788250.jpg

我们的请求体被二进制序列化了,这也是tsrpc的特点之一,我们会在稍后的段落中对tsrpc各个特性做介绍,但是此时此刻我们已经完成了一个api的后端开发->test测试->前端调用。

完善我们的程序

上一个部分相信大家已经学会了如何使用tsrpc开发第一个api,这个部分结合了tsrpc的视频教程中的案例,我们需要做一个简单的CRUD,使用mongoDB。

我们需要在本地启动我们的mongoDB服务,然后我们需要添加一些代码到后端backend项目中。

代码开始之前,我们需要安装mongoDB的依赖,我们可以更方便的引入类型定义以及各种数据库方法。

npm install mongodb -s

为了和视频教程统一,我们的工具类/架构方式,将直接挪用视频教程中的代码:

  • 写一个数据库的表模型,名为Post.ts
// shared/protocols/models/Post.ts

export interface Post {
  _id: string;
  author: string;
  title: string;
  content: string;
  visitedNum: number;

  create: {
    uid: string;
    time: Date;
  };

  update?: {
    uid: string;
    time: Date;
  };
}

我们的数据库模型是需要共享到前端的,方便前端工程能够复用,但是为了确保后端的类型安全,我们需要在模型上多做一层处理。mongodb的id属性不是string,而是ObjectID,所以我们需要在后端对模型进行类型重写(只重写id字段)。

关于为什么要在后端多做一层封装是因为不可能在前端引入mongodb中的objectID

// shared/protocols/models/dbItems/DbPost.ts

import { ObjectID } from "mongodb";
import { Overwrite } from "tsrpc";
import { Post } from "../Post";

export type DbPost = Overwrite<Post, {
    _id: ObjectID
}>

我们使用tsrpc提供的Overwrite泛型对刚刚写的Post类型进行改写,将mongodb中的objectID类型引入进来进行替换,然后我们后端工程就要使用这个Dbpost类型,而不是刚刚我们写的Post类型。

  • 数据库相关配置
// 配置文件
// shared/protocols/models/BackConfig.ts (models文件夹是新建的)
export const BackConfig = {
  // 替换数据库url,数据库名test
  mongoDb: "mongodb://localhost:27017/test",
};
  • 定义数据库初始化类
// 全局一个类,里面写了初始化数据库的内容
// shared/protocols/models/Global.ts

import { Collection, Db, MongoClient } from "mongodb";
import { Logger } from "tsrpc";
import { BackConfig } from "./BackConfig";
import { DbPost } from "./dbItems/DbPost";

export class Global {
  static db: Db;

  static async init(logger?: Logger) {
    logger?.log(`Start connecting db...`);
    const client = await new MongoClient(BackConfig.mongoDb).connect();
    logger?.log(`Db connected successfully...`);
    this.db = client.db();
  }

  static collection<T extends keyof DbCollectionType>(
    col: T
  ): Collection<DbCollectionType[T]> {
    return this.db.collection(col);
  }
}

export interface DbCollectionType {
  Post: DbPost;
}
  • 改写后端index.ts
// backend/index.ts 添加如下内容

import { Global } from "../src/shared/protocols/models/Global";

async function main() {
    // Auto implement APIs
    await server.autoImplementApi(path.resolve(__dirname, 'api'));
    // TODO:在这里初始化了数据库
    await Global.init(server.logger);
    await server.start();
};

ok,截止到目前,我们把第一张表的相关配置已经搞定了,请确保数据库已打开且配置正确,然后我们直接运行
一下服务器:

npm run dev

如果你运气好(狗头),那么你应该是成功开启这个服务器,并且控制台能看到连接成功的信息:

WX20210719-223641@2x.png

然后我们快速开发一下新增API,其他的更新和删除API,希望能大家举一反三,自行开发。

// shared/protocols/PtlAddPost.ts

import { Post } from "./models/Post";

export interface ReqAddPost {
  newPost: Omit<Post, "_id" | "create" | "update" | "visitedNum">;
}

export interface ResAddPost {
  insertedId: string;
}

我们规定的请求类型是只能让客户端传递除了id,create,update,visitedNum的Post类型。
然后我们还是运行那几个熟悉的命令:

npm run proto
npm run api
// api/ApiAddPost.ts

import { ApiCall } from "tsrpc";
import { Global } from "../shared/protocols/models/Global";
import { ReqAddPost, ResAddPost } from "../shared/protocols/PtlAddPost";

export async function ApiAddPost(call: ApiCall<ReqAddPost, ResAddPost>) {
  let op = await Global.collection("Post").insertOne({
    ...call.req.newPost,
    create: {
      uid: "xxx",
      time: new Date(),
    },
    visitedNum: 0,
  });
  call.succ({
    insertedId: op.insertedId.toHexString(),
  });
}

这一part完成~

如何做到动态类型校验

之前我们就提到过,前端在调用后端的api时候,会给出完整的代码提示,从api名称到api的请求体类型等等,那么这一定程度上杜绝了开发中常见的接口联调不细心的问题。在传统的前后端开发中,尤其是分离模式,有一个非常常见的问题就是动态类型校验。每个语言/框架都有自己类型校验的手段,比如springmvc我们可以通过注解的方式来校验(下面展示了控制器中的校验,还有其他校验手段):

@Controller
@RequestMapping("valid")
@Slf4j
public class ValidateController {
 private static final String BASE_PATH = "/valid/";
 @RequestMapping("index")
 public String index(@Validated() Student student,BindingResult result){ 
        return BASE_PATH + "index";
    }
}

那么tsrpc是如何保证数据传输的正确性的呢,首先我们如果在前端使用tsrpc的浏览器请求包,我们调用api时候不仅会在开发中提示开发者这个字段是错误的,而且会在请求发出之前做前端方面的遏制。在后端请求到达异步函数之前,也会去做第三次校验;所以我们在后端异步函数中使用到的参数一定是类型安全,完全不需要担心安全问题。

市面上有很多js领域解决动态校验的方案;最常见应该就是json schema,可以基于json自己实现一套校验方法可以在运行时来做校验。但是仍然有很多缺点,比如不能在前端进行运行时提示且可能重复写很多类型定义。那么tsrpc核心中使用到了一个库(这个库也是同个作者开发的):

tsrpc-buffer

为了实现ts动态类型校验,不可能把整个ts加进去,因为那有足足60m多,这是不现实的。所以作者开发了这个库。tsrpc依赖了这个库,它对ts的语法进行了兼容,目前支持了大部分的ts的写法,包括我们常用的string,number等,还支持一些复杂的泛形。

如果你想细细了解这方面,可以看一下文档支持的ts类型有哪些

当然,随着ts的更新,这个buffer也会支持更多的ts类型,可以做更完善的全栈应用。而且我们可以使用tsrpc进行原汁原味的ts开发,市面上的第三方工具/框架需要借助另外编程语言/DSL,tsrpc-buffer完全让你使用ts,你不会感觉到一丝违和感。

二进制序列化

tsrpc的二进制序列化机制是由我们上文中提到的tsrpc-buffer中实现的,那么这个特性带给我们的是比json更小的传输体积且支持更多的数据类型,ArrayBuffer, Date等。这意味着使用tsrpc的全栈应用在应对上传图片这种业务的时候简直就像是小儿科,我们可以用一个例子来证明。

// 定义一个协议在后端,PtlUpload.ts
export interface ReqUpload {
    fileName: string,
    fileData: Uint8Array
}

export interface ResUpload {
    url: string;
}

我们通过刚刚学到的一些命令,来生成协议以及api

npm run proto
npm run api
// api实现, 先提前把uploads文件夹建立好,或者使用mkdir方法
import { ApiCall } from "tsrpc";
import { ReqUpload, ResUpload } from "../shared/protocols/PtlUpload";
import fs from "fs/promises";

export async function ApiUpload(call: ApiCall<ReqUpload, ResUpload>) {
  await fs.writeFile("uploads/" + call.req.fileName, call.req.fileData);
  call.succ({
    url: "http://127.0.0.1:3000/uploads/" + call.req.fileName,
  });
}

为了让前端调用,同步shared下的协议

npm run sync

写一个简单的file选择器在index.html中

<input type="file" id="fileInput">
// index.ts
import { HttpClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  logger: console,
});

const input = document.getElementById("fileInput") as HTMLInputElement;

input.addEventListener("change", async () => {
  if (input.files) {
    const fileData = await loadFile(input.files?.[0]);
    upload(fileData, input.files?.[0].name);
  }
});

const upload = async (fileData: Uint8Array, fileName: string) => {
  const fr = new FileReader();
  client.callApi("Upload", {
    fileData,
    fileName,
  });
};

function loadFile(file: File): Promise<Uint8Array> {
  return new Promise((rs) => {
    let reader = new FileReader();
    reader.onload = (e) => {
      rs(new Uint8Array(e.target!.result as ArrayBuffer));
    };
    reader.readAsArrayBuffer(file);
  });
}
// 同步到前端
npm run sync

开发完毕,我们可以仔细看一下控制台:

1626218954109.jpg

1626218957442.jpg

尽管我们在日常开发中会用到一些组件库,组件库帮助我们做了上传的大部分工作,所以我们写原生的上传可能在代码量上更多,但是省去了前后端转换Formdata的时间。

向后兼容http(json)和WebSocket

tsrpc也向后支持json,我们可以在客户端进行一个简单的配置,发送的请求就是json啦:

// Create Client
let client = new HttpClient(serviceProto, {
  server: "http://127.0.0.1:3000",
  json: true,
  logger: console,
});

1626391401179.jpg

我其实暂时没有想到非要使用json的场景,使用二进制序列化比json体积更小传输更快,本地开发的日志也在控制台随时打印,所以我还是建议大家使用默认的二进制序列化的传输模式。

tsrpc设计之初是为了游戏,因为传输特性能让websocket更高效,我们可以用tsrpc简单做一个websocket-demo,具体实现我参考了官网的实现,如果你想直接了解官网的这一part的内容,直接移步:

websocket实时服务-tsrpc

tsrpc的实现和协议无关,意味着咱们之前写的代码都可以用,仅仅做一个简单的调整替换即可。

websocket的消息是tsrpc传输中最小单元,我们需要用另外一个方法去定义协议,我们的websocket例子如下:

客户端发起一个请求,服务端接收并且向所有客户端发送一个消息

首先我们需要定义一个MsgHello.ts这样的协议:

// shared/protocols/MsgHello.ts

export interface MsgHello {
  time: Date;
  content: string;
}

这个协议规定了前后端通讯的请求体。

我们需要改写后端backend中的index.ts,将原先的HTTP服务,改成Websocket服务

import { HttpServer, WsServer } from "tsrpc";

export const server = new WsServer(serviceProto, {
    port: 3000,
    logMsg: true
});

这里导出server是有用意的,我们将在之后的代码中会用到这个server。

改写frontend前端中的index.ts

import { HttpClient, WsClient } from "tsrpc-browser";

let ws = new WsClient(serviceProto, {
  server: "ws://127.0.0.1:3000",
  logger: console,
});

const init = async () => {
  // 与后端webscoket服务建立连接
  let result = await ws.connect();
};

init();

我们需要一个api来触发后端给client发送websocket消息:

// shared/protocols/PtlSend.ts

export interface ReqSend {
  content: string;
}

export interface ResSend {
  time: Date;
}

定义成功后,我们运行以下几个命令:

npm run proto
npm run api

运行成功,我们可以在api文件夹下的ApiSend.ts中写入以下内容:

import { ApiCall } from "tsrpc";
// 这里引入banckend/index.ts 导出的server
import { server } from "..";
import { ReqSend, ResSend } from "../shared/protocols/PtlSend";

export async function ApiSend(call: ApiCall<ReqSend, ResSend>) {
  const time = new Date();
  call.succ({
    time,
  });
  // 广播给所有客户端
  server.broadcastMsg("Hello", {
    content: call.req.content,
    time,
  });
}

我们的后端逻辑写完了,我们运行以下命令,将协议同步到前端

npm run sync

我们进一步改写前端frontend/src/index.ts:

import { HttpClient, WsClient } from "tsrpc-browser";
import { serviceProto } from "./shared/protocols/serviceProto";

let ws = new WsClient(serviceProto, {
  server: "ws://127.0.0.1:3000",
  logger: console,
});

const init = async () => {
  let result = await ws.connect();
  console.log(result)
  if (result.isSucc) {
    // ws.callApi
    ws.callApi("Send", {
      content: "hello websocket",
    });
  }
};

init();

我们在页面初始化的时候,向后端发送刚刚写好的SendApi,这个时候我们既能收到api的返回,也能收到websocket的消息推送。

WX20210719-073153@2x.png

WX20210719-073210@2x.png

可以看到websocket传输也是二进制的,我们在开发中,也能发现,无论是callApi和发送websocket通知,从始至终都有类型推导,永远不会在传输中出现类型上的错误,这就是tsrpc的强大之处。

多平台

tsrpc支持多个平台,支持浏览器/小程序/原生ios 安卓/nodejs,甚至它还支持serverless,可以使用tsrpc开发基于阿里云/腾讯云的云函数;在后续我也会对tsrpc生态开发更多插件,使其兼容uniapp&unicloud,让他严格严格意义上跨多端,我相信tsrpc可以改变unicloud的开发习惯,让全栈应用更简单。

结语

本篇文章所有的知识点均在官网&视频教程有体现,视频教程在文章开始之前就有链接,非常希望大家能够先去看一下那个视频。tsrpc的教程还会出,下一篇关于tsrpc文章主要还是讲一下如何和serverless(unicloud)融合。这篇文章正在写大纲,相信也会在这个月之内能和大家见到。

W.png

2021年前端面经

目前本人已经有了新的起点,之前在博客的求职贴也就作废了,这是我第一次写面经,写这个面经的目的希望大家能了解:

  1. 不要当作面试题背诵,因为题解仅仅是思路你会发现我的题解都比较浅甚至是有很多错误。
  2. 不要拿此题去面试其他人,因为好的面试官我认为不应该是行走的题库,应该考虑候选人是否符合团队技术栈和用人需求,不应该拘泥于业务题目,我认为多问问项目比问100道题解要有用的多。
  3. 分享出来也是记录自己的心路历程,希望大家对西安目前的招聘要求有一个大概的了解,如果你觉得我的题解有哪些错误,那就来打我吧(写评论区,我们探讨一下,我会改正的呢)
  4. 题目有些是我面试的时候必问的,有些是来自于沃趣,腾讯云,狼人杀团队的面试题,目前题主所在的公司问的题目不属于以下范畴,仅仅当作学习交流使用。
  5. 有些题目的题解我的博客也有文章(比如vue3和vite),所以大家可以去博客中看一下~

在mark一下我的博客和我现在在做的开源项目:

因卓诶-爱分享爱原创的技术博客 ~ 个人博客

最近在做的项目:

swordCodePractice/InternetQuestionBank

项目名字叫:剑指题解,业务题库app,帮助程序圈子的小伙伴们利用碎片化时间进行自我提升的小程序。

JS

事件循环模型,同异步,阻塞非阻塞,消息队列,调用栈,GUI渲染

JS虽然是一个单线程(主线程)的语言,但是它有很多工作线程,比如Ajax,定时器等等。那么同步异步在分布式系统中来讲就是一种消息通讯机制罢了,关注的是是否能立即返回结果。它不能和阻塞和非阻塞概念混为一谈。阻塞指的是等待结果返回之前影响接下来的任务,非阻塞是不影响。当JS引擎执行异步任务的时候,会把这个任务注册成一个函数,通过事件循环模型通知对应的工作线程(比如Ajax),然后主线程会继续执行当下的任务,等到工作线程完成了任务,事件循环模型会将这个异步任务的回调放到消息队列中,当主线程的调用栈为空(有空闲了)就执行消息队列最前面的消息。那么主线程从消息队列中去取任务和执行任务回调的过程就是事件循环模型的特点。

HTML到DOM是由GUI线程控制的,GUI线程和JS线程是互斥的,GUI线程会在JS的调用栈为空的情况下才会执行,因此当JS中有大量的逻辑需要处理是会影响到GUI渲染的。

闭包,作用域(词法环境)

当我们在js创建一个函数的时候,会创建一个闭包,这个闭包像是一个气泡一样,包含了创建时作用域中的变量,闭包不会随着作用域的销毁而无效,闭包保存在内存中,我们可以任何时候可以访问到闭包中的变量(除非js认定它要销毁了才会销毁),我们可以使用闭包来做很多事情,比如实现私有变量,回调函数,包括实现柯里化;关于作用域,JS中有全局作用域函数作用域以及块级作用域,其中全局作用域和函数作用域不难理解,就是直接写在window和脚本顶级位置的代码都处于全局,函数作用域就是在函数声明中的代码处于函数作用域,而块级作用域是es6新增的let const声明的代码(或者with语句以及trycatch),只能在{}这样的块级作用域中使用。

临时性锁区 TDZ

TDZ不仅仅是日常熟知的let,const情况下可能造成的问题,我们在很多地方都可能会出现TDZ。我们先来理解TDZ是什么:我们拿let和const举例子,我们在let声明之前去使用变量会得到ReferenceError这样的错误,而var不会报这样的错。因为es6规定,let声明的变量在他们的词法环境被实例化时候才会被创建,但是当它们词法绑定的时候才可以被访问(词法绑定换句话来说,没有对变量进行运算);如果在词法绑定之前就访问就会报错;那么从词法环境实例化之后到词法绑定完成这中间的过程就叫做TDZ临时锁区

变量提升是JS的变量最基本的特性,不管是let,const,var都有提升特性。let和const在词法绑定(运算)中,如果未赋值,则就是undefined;那么TDZ其他表现,会在typeof,包括函数参数预设值等场景出现。那么TDZ的作用就是让我们能够用正常的数据流去编写代码,所以我们在写代码应该要注意变量声明顺序,还有避免多个变量是同名情况。这些开发恶习是我们不提倡的。

原型链,原型

JS中的原型链是数据结构中链表的最佳体现,JS中的每个对象都有一个__proto__属性,JS中的函数中有一个原型属性prototype。

function foo(){};
foo.prototype.test = "this is test";
// a是foo的实例,a是一个对象,它有一个隐式原型指向了构造函数的原型对象
let a = new foo();
a.test; // this is test 隐式原型可以访问构造函数上的原型方法

Function也是对象,对象的隐式原型是什么呢?隐式原型指向的都是构造函数的原型,Funtion的构造函数还是Function,那么它的隐式原型就是Funtion.prototype;

Funtion.prototype中的__proto__指向了Object,Object的原型指向了Null,按照规定Null没有原型对象,所以Null是所有原型链的最终点,这个链表我们就称之为为原型链。当对象自身找不到属性的时候会去原型链上的每一个原型去找或者返回null;

总结:

1.对象有属性__proto__,指向该对象的构造函数的原型对象。

2.方法除了有属性__proto__,还有属性prototype,prototype指向该方法的原型对象。

函数

函数是JS的第一等公民,函数的大部分核心内容都在以上的章节提到过,那么这个part我们将简单说一下IIFE。

IIFE指得就是立即执行函数,这个概念js的初学者都知道,我们会通常这么写:

(function(){
    console.log("helllo world");
})()

IIFE起到了这样的作用,我们在匿名函数中声明的变量不会被外部访问即不会污染全局

脚本异步加载

在HTML5之前我们在HTML中加载script脚本的时候,是遇到脚本然后会理解执行的,它是可以阻碍下面的DOM渲染的。其次呢就是在H5加入的2个属性,async指的就是,我们异步加载了这个脚本文件并且执行了它,这个过程是不会阻塞下面的DOM渲染的。defer也是同理,但是和async在执行时间上有区别,defer会在DOM渲染完毕执行。所以总结来说,defer更适合我们常理的加载JS的方式。

Promise

在JS的远古时代,我们假设要调用一个接口或者是做一个异步的操作,我们通常会使用回调函数来resolve结果,比如这样:

request(data, (res) => {
    // 我们要通过res中的值进行下一步操作会这样写
    request(res, (res1) => {
        console.log("fuck is so hard!!")
    })
})

当我们进行多个任务嵌套,这就造成了回调地狱,这是非常不优雅的写法,那么Promise就就解决这个问题,我们通过Promise来给返回值一个“诺言”,就像下面这样:

const a1 = new Promise((resolve, reject) => {
    // resolve将会从then中获取
    // reject将会从catch中获取
});
a1.then(res => {}).catch(err => {});

那么我们需要知道,promise有几个状态:

  1. fulfilled(resolve之后)
  2. reject(reject之后)
  3. pending(还未决定,还在等)

那么Promise有哪些特性呢?比如这个状态一改变就不能在改变了,同一时间只能存在一种状态。那么async和await和promise有什么区别呢,async是es7引入的语法糖,专门处理promise的对象,它在写法上更精简了,使得异步代码彻底的变成了同步代码的视觉样子,比我们promise.then这样链式调用更容易阅读和理解。

待补充:Promise A+规范有哪些?

this的理解

在绝大多数的情况下,函数的调用方式决定了this的值,es5里面通过apply, bind, call来执行函数,此时的this就是他们的第一参数。那么后续又引入了箭头函数,箭头函数不提供this绑定,所以当前如果在箭头函数上出现,那么this指向的就是创建函数的词法环境。

Proxy

Proxy是es6的新语法,熟悉Vue2的盆友都知道,vue2的响应式是基于

Object.defineProperty()

而vue3的响应式是用proxy的,但是并不是ref,computed都是用proxy实现的。那么proxy主要的作用有什么呢?proxy可以创建一个对象的代理,实现查找,拦截以及自定义的其他功能。

const target = {};
const proxy = new Proxy(target, {
   get (target, key, receiver) {
       return 567567;
   }
});
console.log(proxy.getData);

proxy可以代理所有ProxyHandler定义的型位,也可以代理13种对象行为;

proxy和object.defineProperty的区别:

  1. 对于深层嵌套的对象,defineProperty需要遍历让每一层代理,这也就是vue2为什么watch的钩子中有一个deep的选项,开启deep就遍历。
  2. 对于数组,defineProperty不能监听关于它的操作,所以vue中.push一个数据到数组中,template不会变就是这个原因,我们必须要concat返回一个新的数组才能被访问到。
  3. defineProperty比proxy兼容性好。

数据类型/引用类型

基本数据类型有7种:number, string, obejct, null, undefined, boolean, symbol

引用数据类型:统称为object:

let a1 = {};
let a2 = {};
console.log(a1 == a2);
console.log(a1 === a2);

引用数据类型的比较是比较在堆内存中的引用;引用数据类型的真实数据是存在堆中,而基础数据类型存在于栈中。

那么这块就是老生常谈的就是==,===的区别,这个说了太多次了,==有隐式转换不严谨,我们在开发中应当使用===进行严格的判断,能够区分不同的数据类型;

我们需要注意的事情是,在堆中的对象是可变的,在栈中的变量是不可变的,我们如果更改了栈中的变量会重新在栈中划分一块区域。

深拷贝和浅拷贝

根据我们上面提到的数据类型相关知识,我们这里的深拷贝和浅拷贝只针对引用数据类型即object。我们知道引用数据类型虽然在栈中有自己的一个区域存储变量,但是同时又在堆中存储了栈中变量的引用,由于其特殊性,我们在对“对象”和“对象”进行浅拷贝操作的时候,仅仅会复制对象的指针,也就是他们还是指向同一个堆内存;但是深拷贝就不一样了,他会从内而外的复制一个新的对象,指向一个新的堆内存,2个对象互不干扰。这就是深拷贝和浅拷贝的区别。

那么浅拷贝和直接赋值有哪些区别呢?

首先赋值操作,肯定就是复制了栈中的对象,赋值之后此刻就是联动状态,a变b也变;浅拷贝唯一的区别就是第一层是否是基本数据类型。浅拷贝不会改变基础数据类型,但是如果对象其中有子对象(object,array)这个时候子对象变,那么浅拷贝之后的对象也会变。

常用的深拷贝和浅拷贝的操作?

浅拷贝就concat, splice, object.assign(这个深拷贝也可以用)等等

深拷贝就最简单的json.stringify或者写一个递归;

柯里化

柯里化的意思就是在计算机科学中,可以把一个函数多个参数的写法转换一系列一个参数的技术。

var curry = fn =>
    judge = (...args) =>
        args.length === fn.length
            ? fn(...args)
            : (arg) => judge(...args, arg)

上面这个是高颜值写法,柯里化通常用于函数式编程,意指提高代码复用,更容易阅读;

模块化

ES6的模块化解决了很多以前JS的依赖管理和开发全局变量污染的情况,这个part将简述几个模块化标准的特点以及区别。

commonJS:

const m = require("./m.js");
m(); // m.***();
// 屈居于模块中的module的export对象导出的是什么

commonjs会遇到require语句会执行并且运行这个脚本,而这个脚本中的内容都会被缓存到内存中,模块可以被多次加载,但是只有第一次加载才会被缓存;NodeJS就是参照了CommonJS的规范,但是NodeJS自己加了一点东西,就是把module.exports指向了一个变量exports;

特点:

  1. 模块中的代码不会污染其他变量,是在模块作用域中。
  2. 模块加载会被缓存,使用模块中的内容会直接从缓存中取,如果需要获取再次获取模块,需要清除缓存。
  3. 模块加载顺序就是代码执行的顺序

AMD

commonjs非常好,但是它require之后需要立即执行这个特性不适合浏览器,所以AMD和CMD就是适合浏览器运行的模块解决方案,AMD是异步加载,加载完模块依赖的包之后,会有一个回调函数给开发者,我们在回调函数中才可以正常的使用模块中的方法,例如下面:

define({
  add: function(x, y){
    return x + y;
  }
});
// 使用它
require("math", (a) => {
    a.add(1, 2)
});
// 定义AMD模块其实有很多参数,我们只列举了最简单的写法

CMD

CMD的API和AMD很像,它支持AMD的所有API,但是CMD推荐的是:

define((require, exports, module) => {
// do something
})

CMD与AMD的区别就是:

  1. AMD提倡依赖前置,即依赖提前执行,CMD推崇依赖就近,依赖是延迟执行

UMD

那么既然CMD和AMD以及CommonJS这么好,我们可以灵活地使用这些模块,就需要UMD了,UMD是一个通用的模块标准,在UMD的实现中,通过判断环境来决定使用哪种模块化,如果环境中AMD的不存在(define)就寻找commonjs,如果commonjs都不支持,那么会把模块暴漏在window或者global中。

ESModule

我们最熟悉的ESmodule来了,其实在esmodule之前,那些规范都是社区定义的,但是esmodule是真正的官方语言层面提出的模块解决方案,那么esmodule对比commonjs有什么优势呢?commonjs和AMD它们一样,都是在运行时才能确定依赖,但是esmodule能做到编译时就能确定模块依赖。

ESmodule是使用,export 和 import这两个关键语句来进行模块引入和导出的,官方定义的模块就是不一样,支持别名,默认引入等等,写法也非常精简,所以esmodule是我们目前开发web浏览器端的代码时最常用的模块标准。

Vue

Vue2核心理念和Vue3核心理念

  1. Vue3的VDOM进行了重写,首先是体积更小了。
  2. 响应式系统的升级,用proxy来劫持数据,使得Vue2响应式系统的一些坑得到了完善(具体什么坑可以看defineProperty的缺点)
  3. 组件内不需要根节点了
  4. DIFF算法更快,标记了所有的静态节点,只需要DIFF动态的内容
  5. 使用组合API区别于传统的OptionAPI,原来的option将逻辑分散到各个地方不宜与维护和阅读,而组合API将同样逻辑的代码组合在一块,高内聚低耦合,阅读维护更加的方便。

style中的scoped原理

每个组件都会在postcss编译中产生一个Hash,在style中写scoped属性之后,在页面的表现就是使用css的选择器选择对应组件的hash。在这个选择器(组件)下的css是不会影响到外部的css的。

Vdom

VD本质就是一个JS对象,我们熟知的React和Vue都是使用VD进行页面渲染的,那么VD有哪些好处呢?我们都知道页面出现都要经过js运算,生成渲染树,绘制这几个阶段,那么组件中如果出现大量的变化是要经过重排/重绘的,为了避免这个情况,我们使用VDOM来表述DOM,它和DOM是一一对应的,然后经过在内存中我们Vdom和dom的比对,然后分批次的更新页面上的dom。那么页面渲染性能就可以得到很好的提升。

而且VD可以跨平台渲染,因为本质就是一个对象,不仅可以把对象编译成DOM,还可以编译成QQ,微信,ios,原生安卓。缺点也是显而易见的,我们需要创建额外的函数去做这个事情,但是也可以依赖工具比如vue-loader等。

domdiff是对比新旧虚拟dom一种算法,分两种情况:

  • Components diff算法
  • Element diff 算法

组件的diff则是查看组件的类型,类型不相同就新替换旧的,类型相同就更新组件属性,然后进入组件递归上述操作。

标签的diff则是查看标签名,如果标签名不同就直接替换,相同就只更新属性,之后进入标签内部递归上述操作。

MVVM(本来不想写)

如果要讨论MVVM,那么就要谈到MVC和MVP了,就简单的概述一下三种架构模式吧。

MVC

MVC是非常流行的架构模式,它分为view,model,controller层,它们都是单向的通信,由view去触发controller,controller再去触发model做数据处理,然后反方向返回给view层,在这个架构中controller层是最薄的,它只做了逻辑分发的作用,一个桥梁衔接。熟知的框架就是SpringMVC。

MVP

MVP是MVC的衍生的一种架构模式,它把controller变成了Presenter,各个部分的通信都是双向的,而这里的视图view则是被动的,在这个架构中,Presenter做了绝大部分工作,而view是最薄的。

MVVM

MVVM也是衍生的一种架构模式,它没有controller,新增了一个viewModel,它保证了view和数据的双向绑定,其中有一个绑定器去做这样的事情,view的变动会反应到viewModel,反之亦然。

谈谈Vite,为什么这么快,主要解决什么问题?

天下武功,唯快不破;Vite作为超现代的本地开发服务器而且是基于ESM的。那么现在很多开发服务器都是基于ESM的,比如SnowPack。Vite本地开发快主要使用ESM,对于TS和HMR使用的是ESbuild的插件,Esbuild是用GO语言原生编译的,所以Vite在TS和HMR都有出色的表现。vite在生产环境使用的是Rollup(和Vue-cli一样)。

Vite还有以下的特点:

  1. 开箱即用,TS,jSX,CSS等
  2. MPA应用或者库模式都是非常简单配置
  3. SSR服务端渲染

想要具体了解vite是如何运作的,可以了解我以前写的一篇浅析vite的文章:

从0开始理解Vite的主要新特性(一)

Vue如何做依赖收集

我们需要事先了解观察者模式,观察者模式是一个观察者和目标组成的,观察者直接订阅目标,当目标做出改变,观察者也要立即做出回应。那么发布订阅模式和观察者模式有些许不一样,发布者只需要做好发布的任务,不关心订阅者有没有订阅。

在Vue的组件中有一个watch去监听set值,监听到set值之后会去调用render从而实现视图上的变化。那么Vue如何知道哪些变量使用了呢?我们在template中访问了这个变量,势必会被getter监听,在getter我们就可以对这个变量进行标识,从而实现变量被使用之后才能被组件中的watcher监听到。这个步骤就是Vue的依赖收集简单原理。

那么上述就是,一个一对多的例子,一个数据变更了,多个用到这个数据的地方都要做出改变,这就是观察者模式要做到的东西。那么在这个里面目标是data数据,观察者就是计算属性,侦听器,视图等。

  • Keep-alive怎么做?原理是?

这个地方我们描述的是vue中内置的持久化状态的组件,而不是http里面的keep-alive,这个需要注意。如果熟悉vue的同学肯定写过这样的代码:

<keep-alive>
    <App/>
</keep-alive>

(keep-alive下文简称ka), ka 包裹了app组件,就代表了app组件中所有状态都会被缓存,它们不会重走生命周期函数,即create和mounted只执行一次,但是请注意vue并不会销毁被缓存的组件,ka本身也不会去渲染任何元素,它是一个抽象组件。业务需求中我们也需要对缓存组件做一些额外初始化处理,但是生命周期只执行一次就很难受,vue给我们提供了activated这样的钩子,当缓存的组件再次渲染之后我们依然可以做一些初始化的工作。

mounted(){
    // 第一次渲染被缓存的组件
},
activated(){
    // 当缓存再次渲染后触发的钩子,但是此时不会触发mounted
},
deactivated(){
    // 当缓存的组件被销毁,触发钩子
}

我们刚刚提到了ka是抽象组件的问题,vue内部是如何不渲染这个ka的呢,是因为在vue组件在初始化生命周期父子组件建立关系时,如果遇到abstract为true的组件则不渲染,而ka就是true;那vue内部实现中,keepalive是如何有缓存功能的?这一块我摘抄一篇文章提到的一段源码。

// src/core/vdom/patch.js
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        i(vnode, false /* hydrating */)
      }

      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm) // 将缓存的DOM(vnode.elm)插入父元素中
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
  }

作者:wilton
链接:https://juejin.cn/post/6844903837770203144
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

ka组件实例componentInstance是undefined但是keepAlive是true(所有被缓存过的组件keepAlive都是true),所以只执行到i(vnode, false / hydrating /)这一段代码,下一步的if没有执行。但是再次访问被缓存的组件的时候,componentInstance是上一次初始化的,所以我们能顺利的走完下一个if即insert(parentElm, vnode.elm, refElm);将缓存的内容插入到dom中。

我们之前提到的ka只会执行一次完整的生命周期,这一块的逻辑和上述一样,组件实例componentInstance不为undefined且keepAlive为true就不会执行mounted。

这就是keep-alive组件的源码实现,我这个题解描述的非常片面,在写这部分的时候,我自身对于ka的理解没有达到源码级别,所以看了这篇文章之后才有一个全新的认识,我把文章po一下:

彻底揭秘keep-alive原理

nextTick原理?怎么用

nextTick是Vue官方提供的一组API,用于获取在vue中下一次更新DOM结束的回调。由于Vue的DOM更新是异步的,所以我们更改DOM之后需要使用这个API来获取最新的DOM。

原理待补充

router原理?

路由这个概念其实是web层面是服务端的概念,但是前端为什么引入了路由概念,是由于spa应用的流行,很多库都有自己的router实现,那么前端路由中的URL和视图有哪些特性呢?

  1. 监听URL变化改变视图
  2. 不会刷新页面

我们使用hash和history来简单构建一下router基于我们的上面两个特性:

  1. hash指的就是URL中的锚点#之后的内容,锚点在HTML中常用于导航,在这个模式下,我们改变hash是不会刷新页面的。
window.addEventListener('hashchange', onHashChange);
function onHashChange(){
    // 通过判断hash来对页面进行变换
}
  1. history模式在外表下和普通URL无异,但是在这个模式下要比hash做更多一层:popstate我们使用这个API来监听URL的变化,但是当直接改变URL或者a标签跳转是不会走popstate的,所以我们需要监听URL变化和a标签跳转:
window.addEventListener('popstate', onPopState)
// 我们通过拦截a标签的事件,自己绑定一个click事件,去写下面的代码
history.pushState(null, '', el.getAttribute('href'));
// 这样手动的更新URL,不会刷新页面
// 判断URL进行刷新试图
...

React

惭愧,react我只学了1-2天,而且直接接触的是hook,react这块只达到了能写的实战水平,但是水平完全够不到vue,因为知识有限,所以这块罗列的题目很少。

useState如何回调?

待补充

类组件 vs 函数组件

待补充

更新state是同步还是异步的?

待补充

hooks带来了什么?

待补充

Webpack/Vite/SnowPack/Babel

简单讲一下webpack的构建过程

  1. 寻找入口文件
  2. 通过入口文件,逐层识别模块依赖,commonjs,amd,esmodule都会被分析,然后获取代码中的依赖。
  3. 然后webpack就要做分析代码 转换代码 编译代码 输出代码这几个重要阶段,在这几个阶段中webpack中的loader和plugin都会起作用。
  4. 输出代码

常用的loader知道有哪些么?css-loader和style-loader是干嘛的?

loader就简单的用css-loader或者scss语法loader,那么题中的css-loader和style-loader都是出现率极高的loader,css-loader主要是分析语法中的@import语句的,还用于处理css和css文件的关联,style-loader会把css挂载到html中的style里面去。

常见的plugins有哪些知道么?

比如说:

  1. htmlwebpackplugins:生成一个html并且自动引入入口文件
  2. HMR plugins:热模块更新的插件

loader和plugins的区别?

loader主要是针对特定文件的分析和编译,而plugins是webpack生态的最重要支柱,它可以干loader干不了的事情,比如说在webpack执行中会广播很多事件,而plugin可以监听这些事件并且处理和改变输出结果。

简单讲一下sourceMap

“简单“讲一下,我也只能简单讲一下了,难的我也不会。首先我们通常前端在部署的时候,会将js,css这类文件进行压缩,通常这部分工作是打包器为我们做的。但是我们想要在生产环境中查看代码错误,而浏览器会将错误定位到我们压缩编译过后的js中,这对我们debug是不利的。所以我们可以使用sourcemap去解决这个问题,它本质是一个map文件,存储了编译过后的代码的位置信息,我们在编译过后的js中引入map文件即可,引入之后浏览器会根据map存储的位置进行反馈给控制台,我们就可以很快进行debug。那么在webpack中,sourcemap有几种的常见模式:

(省略7种常见的.....)我如果真的遇到问我哪7种模式的面试官我就会很无语,webpack成千上万的配置项我咋可能都能背出来,开发中就直接查文档就好了。eval构建速度很快,而且我们sourcemap不用存储很细致的位置信息,所以使用cheap就可以了,综合来看,一般都是下面这个配置:

开发环境:cheap-module-eval-source-map
生产环境: cheap-module-source-map

如何提升webpack打包速度呢?

Webpack: 从9个方面对打包速度提升(和Node.js的美好碰撞)

webpack的热模块更新怎么做的?

这个问题我说实话,就算不会webpack,也都知道热更新,就算不知道热更新这个词语也都知道我们通常在使用一些脚手架快速开发的时候,更改文件能在页面即时同步技术。那么webpack是如何做热更新的呢,webpack中有一个官方维护的plugin:

HotModuleReplacementPlugin | webpack

hmr之前有很多库比如我经常在vscode中用到的live server,能够监听文件的变化通知浏览器进行刷新,之所以hmr的出现风靡技术圈,原因就是以前的livereload机制太扯了。比如一个提交表单场景,你输了很多表单值,你准备进行测试,发现代码中有一个地方有错,你更改之后发现页面刷新,你之前输入的表单值全部没了。我对hmr工作原理理解非常浅,如果你要深入了解这个内容,我不妨推荐给你们一篇文章,我认为全网就这篇文章把hmr讲透彻了。

Webpack HMR 原理解析

简单说一下我的理解,我就是那种喜欢纸上谈兵的(狗头):

  1. 启动webpack时候有watch选项,即文件更改→重新打包→编译到内存中。
  2. webpack-dev-server会监听静态文件变化,然后通知浏览器livereload(重新加载非hmr)
  3. webpack-dev-server会和客户端建立websocket连接,推送给客户端不同类型的消息,比如上一步的静态文件信息,还有变化了的文件更改,还有新模块的hash,后续会根据hash值去和客户端本地文件做比对,达到热模块更新效果。
  4. HotModuleReplacement拿到上述从服务端给的hash之后,hmr插件通过一个runtime模块去请求所有要更新的文件hash列表,再发起请求拿到要更新的所有文件
  5. hmr插件将比对新旧模块区别,然后判断要不要替换,如果选择替换的话,将分析相关文件依赖,是要一起更新的,如果hmr热更新失败,那就触发livereload,刷新浏览器获取最新的文件。

我原本面试主要说的是建立websokect连接,balalalalala...,但是看了上面的分析文章之后,自己简单的概括了一下,收获非常大,hmr的难点应该是在于如何替换,其他倒是很容易理解。

webpack模块打包原理?

暂补充

webpack文件监听怎么做的?

—watch,webpack-dev-server是默认开启的,脱离webpack之后可以用nodemon做监听。

webpack代码分割本质,有什么意义?

应用越来越大,vendor也会变大,某个功能可能只依赖了vendor一点,但是也要把整个包加载,使用代码分割,可以将业务和业务隔离开,按需加载。避免了浏览器加载页面白屏或者加载功能模块性能的问题。

简单聊聊Babel原理吧

我对babel原理一窍不通,惭愧。babel我的理解就是js的翻译官,能够高效的把高级/实验性语法进行低版本兼容。

CSS

WebComponents

WC是现代组件化的一种技术,WC的核心理念就是:

  1. 自定义元素Custom Element
  2. ShadowDom
  3. 模板Template

我们可以用这些技术构建出一个自定义组件,而这个组件的css和js逻辑都是独立于组件本身。

在这以下几种情况,容器不能承载ShadowDom:

  1. 自身已经承载了Input,TextArea
  2. 承载了Img标签的容器

如何让页面元素消失?

这个是我面试必考题,能够考验候选人的CSS方面的想象力,下面简单列举一下我的答案:

  1. 宽高为0
  2. 背景为白色,让用户看不到
  3. 用posotion transform margin 一系列移动的方法把元素移出去看不到
  4. css缩小,旋转可以把元素处理到看不见
  5. 透明度和css可见,透明度可以用opacity和filter实现
  6. 把字体设置为白色让用户看不到

重排和重绘?如何写组件优化重绘重排带来的开销?

重排和重绘是浏览器页面渲染的必经之路,我们在开发中的代码操作可能引出重排和重绘,那么关于重排和重绘,我们只需要记住一句话:我们页面的布局发生了改变就会引起重排,而重新排会影响到重新绘制,更改颜色文字大小等会引起元素重新绘制但是不会进行重排。在我们开发中,浏览器已经做了很多渲染优化,我们只能在特殊的场景能够发现重排和重绘优化带来的红利,但是我们仍然需要关注渲染性能的优化。比如在我们开发组件的时候,可以使用css contain content,让组件变化仅仅组件内部进行重排重绘,而不是影响整个页面。

transform中的translate会引起重排么?

不会的,GUI线程会开启一个新的图层,不会影响普通文档的图层,那么让线程开启一个新的图层的操作也有很多,比如说透明度,过渡动画,3D等等。定位虽然脱离了普通文档流,但是它还是默认图层,所以还是会影响到普通文档流。创建新的图层是要内存消耗的,所以好的东西不要贪杯。

如何更改组件内部样式?(深度选择器)

这个我就暂且默认为Vue的组件吧,我们也在上面提到了Vue组件如果做样式隔离的原理。

  1. <<<选择器(记忆中,scss和less不支持)
  2. 曲线救国,再写一个style,把组件内部的样式写到没scoped的style中
  3. /deep/ 我一般用这个

css中如何获取html元素上的属性

这个题很简单啊,但是在开发中确实非常少用到,下面给大家上代码演示一下:

content:attr(href); // 必须在伪元素中写

有了解过calc么?

和上面那个题是一个智商的题,问出来的人其实我觉得水平不高,但是奈何我们的题解中预判它可能存在,就简单说一下:

width: calc(100px + 40vh - 100px); // 简单举个例子,计算各个属性的css值,运算符之间必须要空格隔开,要不然无效

CSS沙盒

css sandbox其实也是主要解决样式隔离的问题,那么样式隔离的问题,我们有以下几个方法去解决/设计:

  1. namespace在acticle下面用,但是这个场景一般用在H5制作器上,多个组件移动到同一个区域中展示。
  2. Iframe这个是真正意义上的沙盒,但是很多局限性,高度不能自适应等等,因为iframe是上古时代的产物,所以这里不太了解。
  3. Webcomponent的实现之一ShadowDOM可以解决样式隔离的问题,但是如果care兼容性的话还是用第二种iframe的办法吧。

简单聊聊BFC?如何解决BFC造成的问题?

BFC(块级格式化上下文),指的就是在一个如果一个盒子是BFC盒子的话,那么在盒子里面的任何css都不会影响外部,它更像是一个css"作用域",所以我们在css开发中遇到一些问题,包括BFC内的样式重叠等等,我们都可以用BFC把元素独立开,去解决这个问题。

如何触发BFC呢?(没见过没用过我不会写)

  1. body元素就是天然BFC的盒子
  2. overflow 除了auto其他的值都可以解决
  3. 定位 固定定位 绝对定位
  4. display inline-block flex
  5. 浮动 float

清除浮动

清除浮动这种题,我感觉没有脑血栓10年问不出来。我的面试风格就是挑有用的问,浮动都是很老的题目了,清除浮动这样的题以前很常见,但是2021我觉得没人会问了(校招笔试题可能会看到):

  1. overflow: hidden;
  2. 相邻的元素是clear:both
  3. 或者父子都浮动(尽管脑血栓20年才会这么干)
  4. 伪元素加clear:both(推荐)

HTML

有了解过PWA规范么?

pwa是一系列的技术/规范,是由谷歌提出的,我们都称之为渐进式应用(你也可以理解为国外小程序,个人觉得比我们国内小程序功能更实用,但是没有生态没有用户粘度),使用现代webAPI去构建原生一样的跨平台应用。那么这一块的内容有点多的,所以我们就简单讲解用的比较常见的几个:

  1. servicewoker 离线使用
  2. 像APP一样具体推送通知的功能
  3. 像APP一样添加到主屏幕

这一块其实有蛮多内容的,感兴趣的可以自己搜一下。

简单说一下可替换元素吧

在HTML中有一部分元素就叫做可替换元素,它们不受CSS引擎控制,我们往往看到可替换元素的时候,这个元素就本身自己具备了一些普通元素没有特性,比如Img,Input,textArea等等;

就拿Img来说,img是行内元素,行内元素的定义就是不独立一行且无法设置宽高,但是img作为行内可替换元素是可以设置宽高的。

单页应用和多页应用(spa和mpa)的区别?

这两个概念,尤其是spa这个概念是最近几年非常流行的。spa对应的就是mpa,即多页应用;我们传统的前端页面,都是由一个一个html组成的,通过各种link来连接一个一个页面,那么意味着我们每请求一次都要去请求一个新的html进行响应。这个方案在pc端还尚可,但是在移动端中,用户每切换一次页面都是一次巨大的成本,所以spa应用而生,spa也促成了很多前端路由方案,通过url和页面进行绑定,然后由JS去控制显示的视图,这样做的好处就是,切换速度很快,用户体验感好。

那么spa也有一些缺点,比如说是SEO搜索收录不太好,因为搜索爬虫爬取mpa页面会记录每一个HTML的路径,而爬虫遇到spa页面的时候将会大大受限。mpa在首次加载的速度又优于spa,但是切换速度就不如spa了。所以综合来讲,我们web常见的官网,论坛是适合用mpa来做;移动端适合用spa来做;

SSR服务端渲染是什么?

在了解SSR需要了解浏览器部分的页面解析过程(下文就有),SSR对应的是CSR,CSR即客户端渲染,在我们现在前端中,通常都是把HTML渲染,然后在渲染过程中去加载css和js,此时的HTML因为css和js可能没加载完会出现白屏,此时的HTML组装方式是在客户端。那SSR服务端渲染目前的模式是由服务端将css和js进行处理,然后纯粹的返回给客户端一个完整的html模板,此时HTML组装方式是在服务端。那么SSR和CSR各有什么优缺点呢?在我们了解spa的时候就讲到了SEO的例子,那么这边在延申一下,爬虫也分为高级和低级,目前百度谷歌等爬虫库里面存在着很多低级爬虫,低级爬虫只能爬解析url之后的HTML页面,而客户端渲染的spa应用返回的htm都是空的,所以只有高级爬虫才会对js和css文件进行加载然后爬取,所以SEO收录是spa的一个痛点。如果我们开启服务端渲染,那SEO的问题虽然会迎刃而解,但是要在服务端进行HTML的处理势必会造成大量的计算,开销/投入产出比都是我们应该考虑的。

等待补充,同构(即SSR和CSR共存)

浏览器和工程化

HTTP2带来了什么对于前端?

等待补充

输入一个URL到页面呈现中间经历了什么?

  1. 解析URL
  2. 查看浏览器中的缓存
  3. DNS解析
  4. 建立TCP/IP连接
  5. 发送HTTP请求
  6. 服务端处理请求返回报文
  7. 浏览器解析HTML成DOM树
  8. 异步解析css外链成css规则树
  9. 遇到script标签,同步执行(分async和defer进行异步加载)
  10. 将DOM树和css规则树构造成render树
  11. 渲染render树

你如何去架构一个新的前端项目?

等待补充

浏览器如何优化重排和重绘的?

等待补充

如何在浏览器中进行标签页面的通信呢?

postmessage,等待补充

跨域(其实这题应该不会问了)是怎么造成的

等待补充

有开发过浏览器插件么?

我答的很好,但是之后会补充这一块

有了解过微前端么?

我答的很好,但是之后会补充这一块

有了解过serverless么?

我答的很好,但是之后会补充这一块