记一次 GraphQL 真正的详细入门:原生、koa2、的实战分享会

记录了我在组内的技术分享, 有同样需求的同学可以参考一下
分享全程下来时间大约 55分钟

前言

痛点:网上找的资料,文章, GraphQL的官网,一看就是很‘自我’的大神写的(太烂了)完全管读者能不能看懂,举例子只讲用法!不告诉代码怎么实现的(但是当你学完这一篇你就可以看懂了), 并且从来不晒出整体代码,导致根本不知道他们怎么玩的,有被冒犯到!!可以说那些资料都不适合入门。
定位:GraphQL并不是必须用的技术, 但它是必须会的技术,之所以说它必会是因为可以靠它为‘前端’这一行业占领更大的‘领地’, 同时它的思想是值得琢磨与体会的。
是啥:他不是json更不是js, 他是GraphQL自身的语法, 兼容性非常好.
选择:GraphQL为了使开发更高效、简洁、规范而生,如果对工程对团队造成了负担可以果断舍弃(别犹豫,这小子不是必需品),毕竟服务端多一层处理是会消耗性能的,那么就要理智计算他的“损失收益比”了。
前提:学习这门技术需要有一定的“前后交互”的知识储备, 比如会node, 这里会介绍如何在node端使用, 如何集成入koa2项目里面使用, 并且会舍弃一些大家都用的技术, 不做跟风党。

正文

一. GraphQL到底干啥的?用它有啥好处哦?

这里是关键, 一定要引起大家的兴趣, 不然很难进行。

①: 严格要求返回值
比如下面是后端返回的代码

 {
  name:1,
  name:2,
  name:3,
 }

前端想要的代码

{
 name:1
}

从上面可以看出, name2 与 name3 其实我不想要, 那你传给我那么多数据干什么,单纯为了浪费带宽吗?但是吧。。也可理解为某些场景下确实很鸡肋,但是请求多了效果就厉害了。

②: 设想一个场景, 我想要通过文章的id获取作者信息, 再通过作者信息里面的作者id请求他其他的作品列表, 那么我就需要两步才能完成, 但是如果这两个请求在服务端完成那么我们只用写一个请求, 而且还不用找后端同学写新接口。

③: 控制默认值: 比如一个作品列表的数组, 没有作品的时候后端很肯能给你返的是null, 但是我们更想保证一致性希望返回[],这个时候可以用GraphQL进行规避.

二. 原生GraphQL尝鲜。

随便建个空白项目npm install graphql.
入口路径如下src->index.js

var { graphql, buildSchema } = require('graphql');

// 1: 定义模板/映射,  有用mongoose操作数据库经验的同学应该很好理解这里
var schema = buildSchema(`
  type Query {
    # 我是备注, 这里的备注都是单个井号;
    hello: String
    name: String
  }
`);

// 2: 数据源,可以是热热乎乎从mongodb里面取出来的数据
var root = { 
  hello: () => 'Hello!',
  name:'金毛cc',
  age:5
};

// 3: 描述语句, 我要取什么样的数据, 我想要hello与name 两个字段的数据, 其他的不要给我
const query =  '{ hello, name }'

// 4: 把准备好的食材放入锅内, 模型->描述->总体的返回值
graphql(schema, query, root).then((response) => {
  console.log(JSON.stringify(response));
});

上面的代码直接 node就可以,结果如下: {"data":{"hello":"Hello!","name":"金毛cc"}};

逐一攻克
1: buildSchema 建立数据模型

var schema = buildSchema(
// 1. type: 指定类型的关键字
// 2. Query: 你可以理解为返回值的固定类型
// 3. 他并不是json,他是graphql的语法, 一定要注意它没有用','
// 4. 返回两个值, 并且值为字符串类型, 注意: string小写会报错
` type Query {
    hello: String
    name: String
  }
`);

GraphQL 中内置有一些标量类型 String、 Int、 Float、 Boolean、 ID,这几种都叫scalar类型, 意思是单个类型

2: const query = '{ hello, name }'
做外层{}基本不变, hello的意思就是我要这一层的hello字段, 注意这里用','分割, 之后会把这个','优化掉.

到这里一个最基本的例子写出来了, 感觉也没啥是吧, 我当时学到这里也感觉会很顺利, 但是... 接下来文章断层, 官网表达不清, 社区不完善等等问题克服起来好心酸.

三. 利用库更好的原生开发

毕竟这样每次node命令执行不方便, 并且结果出现在控制台里也不好看, 所以我们要用一个专业工具'yoga'.

yarn add graphql-yoga

const { GraphQLServer } =  require('graphql-yoga');

// 类型定义 增删改查
const typeDefs = `
  type Query{
    hello: String! #一定返回字符串
    name: String
    id:ID!
  }
`
const resolvers = {
  Query:{
    hello(){
      return '我是cc的主人'
    },
    name(){
      return '鲁鲁'
    },
    id(){
      return 9
    },
  }
}
const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(()=>{
  console.log('启动成功, 默认是4000')
})

当然最好用nodemon来启动文件 npm install nodemon -g

  1. hello: String! 像这种加了个'!'就是一定有值的意思, 没值会报错.
  2. Query 这里定义的返回值, 对应函数的返回值会被执行.
  3. new GraphQLServer 的传参 定义的数据模型, 返回值, 因为具体的请求语句需要我们在web上面输入.
  4. id的类型使用ID这个会把id转换为字符串,这样设计也许是为了兼容所有形式的id.
  5. server.start 很贴心的起一个服务配置好后效果如下:左边是输入, 右边是返回的结果

jm.png

四. 多层对象定义

我们返回data给前端,基本都会有多层, 那么定义多层就要有讲究了

const {GraphQLServer} =  require('graphql-yoga');
const typeDefs = `
  type Query{
     me: User!  # 这里把me这个key对应的value定义为User类型, 并且必须有值
  }
  type User { # 首字母必须大写
    name:String
  }
`
const resolvers = {
  Query:{
    me(){
      return {
        id:9,
        name:'lulu'
      }
    }
    
  }
}
const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(()=>{
  console.log('启动成功, 默认是4000')
})
  1. User类型不是原生自带 , 所以我们要自己用type关键字定义一个User数据类型.(首字母必须大写)
  2. Query里面return的值, 必须满足User类型的定义规则

当我们取name的值时:
id.png

我刚才故意在返回值里面写了id, 那么可以取到值么?

did.png

结论: 就算数据里面有, 但是类型上没有定义, 那么这个值就是取不到的.

五. 数组

定义起来会有些特殊

const { GraphQLServer } = require('graphql-yoga');

const typeDefs = `
  type Query {
    # 返回是数组
    arr:[Int!]! 
  }
`
const resolvers = {
  Query: {
    arr() {
      return [1, 2, 3, 4]
    }
  }
}

const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(() => {
  console.log('启动成功, 默认是4000')
})
  1. arr:[Int!] 如果写成 arr:[] 会报错, 也就是说必须把数组里面的类型定义完全.
  2. Query里面的返回值必须严格按照type里面定义的返回, 不然会报错.

结果如下:
arr.png

六. 传参(前端可以传参数,供给服务端函数的执行)这个思路很神奇吧.

const { GraphQLServer } = require('graphql-yoga');

const typeDefs = `
  type Query{
     greeting(name: String):String # 需要传参的地方, 必须在这里定义好
     me: User!
  }
  type User { # 必须大写
    name:String
  }
`
const resolvers = {
  Query: {
    // 四个参数大有文章
    greeting(parent, args, ctx, info) {
      return '默认值' + args.name
    },
    me() {
      return {
        id: 9,
        name: 'lulu'
      }
    }

  }
}

const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(() => {
  console.log('启动成功, 默认是4000')
})
  1. greeting(name: String):String greeting是key没的说, 他接收一个name参数为字符串类型, 这里必须指明参数名字, 返回值也必须是字符串类型, 也就是greeting是一个字符串.
  2. greeting(parent, args, ctx, info) { 这里我们用到 args也就是参数的集合是个对象, 我们args.name就可以取到name的值, 剩下的值后面用到会讲.
  3. 既然说了要传参, 那就必须传参不然会报错

changcan.png

因为左侧的参数是要放在url请求上的, 所以要用双引号;

七. 关联关系

就像数据库建表一样, 我们不可能把所有数据放在一张表里, 我们可能会用一个id来指定另一张表里面的某些值的集合.
const { GraphQLServer } = require('graphql-yoga');

const typeDefs = `
  type Query{
    lulu: User!
  }
  type User{
    name:String
    age: Int
    chongwu: Chongwu!
  }
  type Chongwu{
     name:String!
     age:Int
  }
`
// 自定义的数据
const chongwuArr = {
  1: {
    name: 'cc',
    age:8
  },
  2: {
    name: '芒果',
    age:6
  },
  9: {
    name: '芒果主人',
    age:24
  }
}

const resolvers = {
  Query: {
    lulu() {
      return {
        name: '鲁路修',
        age: 24,
        chongwu: 9
      }
    },
  },
  // 注意, 它是与Query并列的
  User:{
    // 1: parent指的就是 user, 通过他来得到具体的参数
    chongwu(parent,args,ctx,info){
        console.log('=======', parent.chongwu ) // 9
        return chongwuArr[parent.chongwu]
    }
  }
}

const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(() => {
  console.log('启动成功, 默认是4000')
})

这里数据量有点多, 我慢慢解析

  1. lulu属于User类, User类里面的chongwu(宠物)属于Chongwu类, 我们需要根据chongwu输入的id 查询出 展示哪个宠物.
  2. 由于这个宠物的列表可能来自外部, 所以他的定义方式需要与Query同级.
  3. parent 指的就是父级数据, 也就是通过他可以获取到输入的id.

效果如下:
ddai.png

这里就可以解释刚开始的一个问题, 就是那个通过文章id找到作者, 通过作者找到其他文章的问题, 这里的知识点就可以让我们把两个接口合二为一, 或者合n为一.

八. 不是获取, 是操作.

有没有发现上面我演示的都是获取数据, 接下来我们来说说操作数据, 也就是'增删改'没有'查'
graphql规定此类操作需要放在Mutation这个类里面, 类似vuex会要求我们按照他的规范进行书写
const { GraphQLServer } = require('graphql-yoga');

const typeDefs = `
  type Query{
    hello: String!
  }
  # 是操作而不是获取, 增删改:系列
  type Mutation{
    createUser(name:String!, age:Int!):CreateUser

    # 这里面可以继续书写create函数...
  }

  type CreateUser{
    id:Int
    msg:String
  }
`

const resolvers = {
  Query: {
    hello() {
      return '我是cc的主人'
    },
  },
  // query并列
  Mutation: {
    createUser(parent, args, ctx, info) {
      const {name,age} = args;
      // 这里我们拿到了参数, 那么就可以去awit 创建用户
       return {
         msg:'创建成功',
         id:999
       }
    }
  }
}



const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(() => {
  console.log('启动成功, 默认是4000')
})
  1. Mutation是特殊类, 也是与Query并列.
  2. 一个Mutation里面可以写多个函数, 因为他是个集合.
  3. 为函数的返回值也可以定义类型

效果如下: 接收id与提示信息
creat.png

九. input特殊类型

const { GraphQLServer } = require('graphql-yoga');

const typeDefs = `
  type Query{
    hello: String!
  }
  # 是操作而不是获取, 增删改:系列
  type Mutation{
    # 这个data随便叫的, 叫啥都行, 就是单独搞了个obj包裹起来而已, 不咋地 
    createUser(data: CreateUserInput):CreateUser
  }

  type CreateUser{
    id:Int
    msg:String
  }
   
  # input 定义参数
  input CreateUserInput{
    # 里面的类型只能是基本类型
    name: String!
    age:Int!
  }
`

const resolvers = {
  Query: {
    hello() {
      return '我是cc的主人'
    },
  },
  // query并列
  Mutation: {
    createUser(parent, args, ctx, info) {
      // **这里注意了, 这里就是data了, 而不是分撒开的了**
      const { data } = args;
      return {
        msg: '创建成功',
        id: 999
      }
    }
  }
}

const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(() => {
  console.log('启动成功, 默认是4000')
})
  1. 把参数放在data里面, 然后定义data的类
  2. 注意三个关键点的代码都要改

效果与上面的没区别, 只是多包了层data如图:
data.png

这个只能说有利有弊吧, 多包了一层, 还多搞出一个类, 看似可以封装了实则'鸡肋啊鸡肋'

十. '更鸡肋的'MutationType特殊类型

const {GraphQLServer} =  require('graphql-yoga');

// 非常鸡肋, 这种事做不做, 该不该你做, 心里没点数
const typeDefs = `
  type Query{
    hello: MutationType
  }

  enum MutationType{
    aaaa
    bbbb
    cccc
  }
`
const resolvers = {
  Query:{
    hello(){
      // 只能返回菜单里面的内容, 这样可以保证不出格... p用
      return 'bbbb'
    },
  }
}

const server = new GraphQLServer({
  typeDefs,
  resolvers
})

server.start(()=>{
  console.log('启动成功, 默认是4000')
})
  1. 我定义了一个MutationType的类, 限制只能用'aaa','bbb','ccc'中的一个字符串.
  2. 这不猫捉耗子么? graphql本身定位不是干这个事的, 这种事情交给统一的数据校验模块完成, 他做了校验的话那么其他情况他管不管? 关了又如何你又不改数据, 就会个报错公鸡想下蛋.
  3. 完全不建议用这个, 当做了解, 具体的校验模块自己在中间件或者utils里面写.

十一. 集成进koa2项目

1. 终于到实战了, 讲了那么多的原生就是为了从最基本的技术点来理解这里
2. 并不一定完全使用graphql的规范, 完全可以只有3个接口用它
3. 我们刚才写的那些type都是在模板字符串里面, 所以肯定有人要他模板拆解开, 以对象的形式去书写才符合人类的习惯.

先建立一个koa的工程
// 若果你没有koa的话, 建议你先去学koa, koa知识点比较少所以我暂时没写相应的文章.
koa2 graphqlx // main:工程名 不要与库重名
npm install graphql koa-graphql koa-mount --save 大朗快把库安装好.

app.js文件里面
const Koa = require('koa')
const app = new Koa()
const views = require('koa-views')
const json = require('koa-json')
const onerror = require('koa-onerror')
const bodyparser = require('koa-bodyparser')
const logger = require('koa-logger')

////// 看这里
const mount = require('koa-mount');
const graphqlHTTP = require('koa-graphql');
const GraphQLSchema=require('./schema/default.js');
//////

const index = require('./routes/index')
const users = require('./routes/users')

// error handler
onerror(app)

// middlewares
app.use(bodyparser({
  enableTypes:['json', 'form', 'text']
}))
app.use(json())
app.use(logger())
app.use(require('koa-static')(__dirname + '/public'))

app.use(views(__dirname + '/views', {
  extension: 'pug'
}))

// 每一个路径, 对应一个操作
app.use(mount('/graphql', graphqlHTTP({
  schema: GraphQLSchema,
  graphiql: true // 这里可以关闭调试模式, 默认是false
})));

// logger
app.use(async (ctx, next) => {
  const start = new Date()
  await next()
  const ms = new Date() - start
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

// routes
app.use(index.routes(), index.allowedMethods())
app.use(users.routes(), users.allowedMethods())

// error-handling
app.on('error', (err, ctx) => {
  console.error('server error', err, ctx)
});

module.exports = app
  1. 我直接吧默认配置也粘进来了, 这样可以保证你拿走就用
  2. graphiql: true 这个时候开启了调试模式 会出现下图的调试界面, 默认是false
  3. mount 来包裹整体的路由
  4. graphqlHTTP 定义请求相关数据
  5. GraphQLSchema 使我们接下来要写的一个操作模块.

scxs.png

这个画面是不是似曾相识!

schema->default.js
const {
  GraphQLID,
  GraphQLInt,
  GraphQLList,
  GraphQLString,
  GraphQLSchema,
  GraphQLNonNull,
  GraphQLObjectType,
  GraphQLInputObjectType,
} = require('graphql');

// id对应的详情
let idArr = {
  1:{
    name:'我是id1',
    age:'19'
  },
  2:{
    name:'我是id2',
    age:'24'
  }
} 

// 定义id的类
let GID= new GraphQLObjectType({
  name: 'gid',
  fields: {
    name: { type: GraphQLString },
    age: { type: GraphQLString },
  }
})

// 参数类型 不太对
let cs = new GraphQLInputObjectType({
  name:'iddecanshu',
  fields: {
    id: { type: GraphQLString },
  }
})

//定义导航Schema类型
var GraphQLNav = new GraphQLObjectType({
  name: 'nav',
  fields: {
    cc:{ // 传参
      type:GraphQLString,
      // args:new GraphQLNonNull(cs), // 1; 这种是错的
      args:{
        data: {
          type:new GraphQLNonNull(cs), // 这种可以用data为载体了
        } 
      },
      // args:{ // 3:这种最好用了。。。
      //   id:{
      //     type:GraphQLString
      //   }
      // },
      resolve(parent,args){
        return '我传的是' + args.data.id
      }
    },
    // greeting(name: String):String 
    title: { type: GraphQLString },
    url: { type: GraphQLString },
    id: {
      // type:GraphQLList(GID), // 这里很容易忽略
      type:GraphQLNonNull(GID), // 反复查找也没有专门obj的 这里用非空代替
      async resolve(parent,args){
        // console.log('wwwwwwwww', idArr[parent.id])
        // 这个bug我tm。。。。。
        // 需要是数组形式。。。。不然报错
        // "Expected Iterable, but did not find one for field \"nav.id\".",
        // return [idArr[parent.id]];

        // 2: 更改类型后就对了
        return idArr[parent.id] || {}
      }
    },
  }
})

//定义根
var QueryRoot = new GraphQLObjectType({
  name: "RootQueryType",
  fields: {
    navList: {
      type: GraphQLList(GraphQLNav),
      async resolve(parent, args) {
        var navList = [
          { title: 'title1', url: 'url1', id:'1' },
          { title: 'title2', url: 'url2', id:'2' }
        ]
        return navList;
      }
    }
  }
})

//增加数据
const MutationRoot = new GraphQLObjectType({
  name: "Mutation",
  fields: {
    addNav: {
      type: GraphQLNav,
      args: {
        title: { type: new GraphQLNonNull(GraphQLString) },
      },
      async resolve(parent, args) {
        return {
          msg: '插入成功'
        }
      }
    }
  }
})

module.exports = new GraphQLSchema({
  query: QueryRoot,
  mutation: MutationRoot
});

十二. koa2中的使用原理"逐句"解析

①引入
  1. 这个是原生自带的, 比如我们会把GraphQLID 这种象征着单一类型的类单独拿到.
  2. 定义type的方法也变成了 GraphQLObjectType这样的实例化类来定义.
const {
  GraphQLID,
  GraphQLInt,
  GraphQLList,
  GraphQLString,
  GraphQLSchema,
  GraphQLNonNull,
  GraphQLObjectType,
  GraphQLInputObjectType,
} = require('graphql');
②单一的类
  1. 我们实例化GraphQLObjectType导出一个'type'
  2. 使用 type:GraphQLString的形式规定一个变量的类型
  3. name: 这里的name可以理解为一个说明, 有时候可以通过获取这个值做一些事.
let GID= new GraphQLObjectType({
  name: 'gid',
  fields: {
    name: { type: GraphQLString },
    age: { type: GraphQLString },
  }
})
③ 定义根类
  1. fields必须要写, 在它里面才可以定义参数
  2. GraphQLList意思就是必须为数组
  3. type不能少, 里面要规定好这组返回数据的具体类型
  4. resolve也是必须有的没有会报错, 并且必须返回值与type一致
var QueryRoot = new GraphQLObjectType({
  name: "RootQueryType",
  fields: {
    navList: {
      type: GraphQLList(GraphQLNav),
      async resolve(parent, args) {
        var navList = [
          { title: 'title1', url: 'url1', id:'1' },
          { title: 'title2', url: 'url2', id:'2' }
        ]
        return navList;
      }
    }
  }
})

十三. koa2里面的关联关系与传参

这里的关联关系是指, 之前我们说过的 id 指向另一个表
let GID= new GraphQLObjectType({
  name: 'gid',
  fields: {
    name: { type: GraphQLString },
    age: { type: GraphQLString },
  }
})
var GraphQLNav = new GraphQLObjectType({
  name: 'nav',
  fields: {
    cc:{
      type:GraphQLString,
      args:{
        data: 
          type:new GraphQLNonNull(cs), // 这种可以用data为载体了
        } 
      },
      resolve(parent,args){
        return '我传的是' + args.data.id
      }
    },
    id: {
      type:GraphQLNonNull(GID),
      async resolve(parent,args){
        return idArr[parent.id] || {}
      }
    },
  }
})
  1. 上面cc这个变量比较特殊, 他需要args这个key来规范参数, 这里可以直接写参也可以像这里一样抽象一个类.
  2. id他规范了id对应的是一个对象, 里面有name有age
  3. cc想要拿到传参就需要args.data 因为这里我们用的input类来做的

实际效果如图所示:

ccnz.png

十四. 对集成在koa2内的工程化思考(数据模型分块)

1. 从上面那些例子里面可看出, 我们可以用/api/blog这种路由path为单位, 去封装一个个的数据模型
2. 每个模型里面其实都需要操作数据库
3. 说实话增加的代码有点多, 这里只演示了2个接口就已经这么大了
4. 学习成本是不可忽略的, 而且这里面各种古怪的语法报错

十五. 前端的调用

这里我们以vue为例
import axios from "axios"; 这个是前提
query= 这个是关键点, 我们以后的参数都要走这里

方式1(暴力调取)
created(){
    // 1: 查询列表
    // ①: 一定要转码, 因为url上不要有{} 空格
    axios
      .get(
        "/graphql?query=%7B%0A%20%20navList%20%7B%0A%20%20%20%20title%0A%20%20%20%20url%0A%20%20%7D%0A%7D%0A"
      )
      .then(res => {
        console.log("返回值: 1", res.data);
      });
     }
方式2(封装函数)
  methods: {
    getQuery() {
      const res = `
        {
          navList {
            title
            url
            id {
              name
              age
            }
          }
        }`;
      return encodeURI(res);
    },
  },
  created() {
    axios.get(`/graphql?query=${this.getQuery()}`).then(res => {
      console.log("返回值: 2", res.data);
    });
  }
方式3(函数传参)
  methods: {
    getQuery2(id) {
      const res = `
        {
          navList {
            cc(data:{id:"${id}"})
            title
            url
            id {
              name
              age
            }
          }
        }`;
      return encodeURI(res);
    }
  },
  created() {
    axios.get(`/graphql?query=${this.getQuery2(1)}`).then(res => {
      console.log("返回值: 3", res.data);
    });
  }

十六. 前端插件的调研

  1. 一看前面的传参方式就会发觉, 这肯定不合理啊, 一定要把字符串解构出来.
  2. vue-apollo技术栈是当前比较主流的, 但是这个库写的太碎了, 并且配置起来还要更改我本来的代码习惯.
  3. 又在github上面找了一些模板的库, 但是并没有让我从书写字符串的尴尬中解脱出来

所以暂时没有找到我认可的库, 当然了自己暂时也并不想把时间放在开发这个插件上.

十七. 我想要的插件什么样的

  1. 可以使我,采用对象或者json的方式来定义query
  2. 不需要定义那么多概念, 开箱即用, 我只需要那个核心的模板解决方案
  3. 不要改变我本来的代码书写方式, 比如说vue-apollo提供了新的请求方式, 可是我的项目都是axios, 我就是不想换 凭什么要换

十八. 学习graphql有多少阻碍

  1. 网上的资料真的对初学者很不友好, 根本不举例子, 导致每个知识点都会我自己试了几十次试出来的.
  2. 光学这一个技术不够, 还要思考服务端的重构与新规范, 前端也要新规范.
  3. 这个技术也出来好几年了, 但是社区真的不敢恭维, 所以啊还是需要自己更多的精力投入进来.

十九. 此类技术趋势的思考

  1. 前端工程师越来越不满足自己的代码只在浏览器上运行了, 我们也要参与到服务端交互的环节中.
  2. 身为当今2020年的前端如果眼光还局限在web端有可能会被后浪排在沙滩上了.
  3. 个人不太喜欢这种抛出很多新的知识点, 和架构体系的库, 应该是越少的学习成本与代码成本做到最多的事.
  4. 前端技术还在摸索阶段, 也可以说是扩展阶段, 预计这几年还是会出现各种新的概念, 但是吧正所谓"合久必分,分久必合", 在不就的将来前端的范围很可能重新划定.

二十. end

说了这么多也是因为学习期间遇到了好多坑, 不管怎么样最后还是可以使用起来了, 在之后的使用过程中还是会不断的总结并记录, 遇到问题我们可以一起讨论.
希望和你一起进步.

阅读 2.6k

推荐阅读