天天修改

天天修改 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织 www.agzgz.com 编辑
编辑

大前端,小程序,全栈开发

个人动态

天天修改 赞了文章 · 1月8日

Babel7 转码(五)- corejs3 的更新

corejs3 的更新

前文介绍了 core-js@2 的配置,而 core-js@3 的更新,带来了新的变化,@babel/polyfill 无法提供 core-js@2core-js@3 过渡,所以现在有新的方案去替代 @babel/polyfill。需要 babel-loader 版本升级到 8.0.0 以上,@babel/core 版本升级到 7.4.0 及以上。

推荐阅读官方的几篇文档,必定大有帮助:

  1. 作者的官方阐述
  2. Babel 7.4.0 版本的更新内容,及官方的升级建议
  3. core-js@2 向core-js@3 升级,官方的 Pull request

@babel/preset-env 也因 core-js@3 的原因,需要配置 corejs 参数去指定使用的corejs 版本,否则 webpack 运行时会报 warning。

在前面的文章(https://segmentfault.com/a/11...)中已经阐述过——想要转译 ES 的新语法 + 新API,可以有以下两套方案:

方案一:@babel/preset-env + @babel/polyfill

方案二:@babel/preset-env + @babel/plugin-transform-runtime + @babel/runtime-corejs2

这两套方案取其一即可,其中,@babel/preset-env 提供转译 ES 新语法,剩下的事情(即 ES 的新 API,例如:Proxy 转译)才是 @babel/polifill@babel/plugin-transform-runtime 需要去解决的事情。

以下,分别给出两种方案在 corejs@3 时的升级策略(如果不清楚这两种方案的异同,仍然推荐阅读这一篇:https://segmentfault.com/a/11... ,新版本只是需要的包、配置不同,内在原理还是一样)。

polyfill 垫片方案 的升级策略

根据 useBuiltIns 配置参数不同,既可以按需加载垫片,也可以不按需。

@babel/polyfill 不必再安装,转而需要依靠 core-jsregenerator-runtime(详细原因请看作者的阐述),替代方案用法如下:

  1. 安装依赖

    yarn add babel-loader @babel/core @babel/preset-env -D
    yarn add core-js regenerator-runtime
  2. .babelrc配置

    {
      "presets": [
        [
          "@babel/preset-env",
          {
            "modules": false, // 对ES6的模块文件不做转化,以便使用tree shaking、sideEffects等
            "useBuiltIns": "entry", // browserslist环境不支持的所有垫片都导入
            // https://babeljs.io/docs/en/babel-preset-env#usebuiltins
            // https://github.com/zloirock/core-js/blob/master/docs/2019-03-19-core-js-3-babel-and-a-look-into-the-future.md
            "corejs": {
              "version": 3, // 使用core-js@3
              "proposals": true,
            }
          }
        ]
      ],
      "plugins": []
    }
  3. js代码里取代原先的import '@babel/polyfill',做如下修改:

    import "core-js/stable"
    import "regenerator-runtime/runtime"
transform-runtime 按需加载方案 的升级策略
  1. 安装依赖

    yarn add babel-loader @babel/core @babel/preset-env @babel/plugin-transform-runtime -D
    yarn add @babel/runtime-corejs3
  2. .babelrc文件配置

    {
      "presets": [
        [
          "@babel/preset-env",
          {
            "modules": false,
          }
        ]
      ],
      "plugins": [
        [
          "@babel/plugin-transform-runtime",
          {
            "corejs": {
              "version": 3,
              "proposals": true
            },
            "useESModules": true
          }
        ]
      ]
    }
查看原文

赞 40 收藏 25 评论 64

天天修改 发布了文章 · 2020-12-27

支持多项目并行开发前端脚手架

在一个小公司最大的好处是什么都要搞,PC/公众号/小程序,前台,后台,中台,react,vue、UI都接触,产品需求不断,项目越做越多,人还是那几个。对于前端的标准化,工程化的要求越来越高。

aotoo-hub作为一套通用型前端脚手架,无技术栈依赖,聚焦于多人协作及工程化。可以帮助开发者快速产出项目,喜欢折腾的可以研究下。

aotoo-hub是一套前端/NODE 端一体化设计的全栈脚手架,前端使用webpack4编译,node端使用koa2提供服务。hub可独立运行作为前端编译工具,也可配合node端部署线上服务

独立运行时,仅作为前端编译、输出静态资源工具,hub采用webpack对前端的资源进行编译,开发模式下由webpack-dev-server提供热更新支持,生产环境下仅产出压缩后的代码

融合运行时,node(koa2,koa-router)将接管webpack-dev-server提供后端服务,此时可实现SSR服务,API服务,可用于部署,提供线上服务

一些特点

  • 提供简单的命令行工具
  • 编译环境支持多项目,共享编译资源及node_module
  • 支持为React/Vue/Jq/原生js/小程序等项目提供编译环境
  • 规范的前端、node端目录结构
  • 支持动态样式(sass/stylus)
  • 支持多环境,命令行切换测试,生产等环境
  • 支持node端(koa2)

GITHUB
更多说明

脚手架源码结构

hub工作空间
  ├── build
  ├── aotoo.config.js
  ├── index.js
  ├── package.json
  ├── postcss.config.js
  └── src
       # vue 项目演示
       └─ vueSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # react 项目演示
       └─ reactSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # 小程序项目演示
       └─ xcxSample 
             ...
             ...
             
       # 文档项目演示
       └─ mdSample 
             ...
             ...

hub脚手架开发的一些思路

前端项目架构比后端项目架构更具有挑战性,为啥呢?一般后端架构(大部分中小项目)稳定在一个环境,语言,项目下,可能几年更新一次,而前端要应对多端输出,项目繁杂,多种框架,复杂组件等等情况,框架的更新还特别活跃,经常有学不动想放弃的感觉。

脚手架作为一个重要前端工具,特别需要有统一的,标准化的思想。好的脚手架能够能让开发,测试、运维的工作变得很爽。我们需要脚手架能约束、规范工程项目结构;有统一的编译环境;与项目去耦合;方便进行多人协作;还应该要简单,开箱即用,开发者只需关注业务,在生成的项目结构的基础上进行开发即可

与项目解耦

为什么需要将架构与项目去耦合,试想下,当一个公司存在n个项目时,架构师更新、维护不同项目,不同框架的脚手架,造成项目不稳定的风险,同时增加了架构师、开发人员、测试人员、运维人员,运营人员的时间成本,沟通成本。

技术栈无关

公司项目类型很多,轻量,且需要快速迭代上线的项目,一般我们用vue;比较复杂,由多人协作共同完成的项目。一般我们用react;小程序也是前端的热门方向,还有老旧基于jq项目,因此脚手架需要能够支持编译多种前端框架

这个不是很难,在webpack中添加各种框架所需的配置就可以实现,hub目前支持React、Vue、Angular、Jquery、小程序等技术栈。

多项目支持

架构与项目去耦合,即可单个项目独立编译运行,又可以同时运行。所有项目共享hub工作空间的编译环境,共享工作空间的node_module。项目自有dist目录,启动时有分配唯一服务端口。如下图所示

工作空间
  ├── build
  └── src
       └─ vueSample
             └─ dist
       └─ reactSample
                └─ dist
       └─ mdSample
             └─ dist
       └─ xcxSample
             └─ dist

命令行工具

命令行需要简洁高效,能够实现环境初始化、项目初始化、开发模式、生产模式,环境切换,可以传递参数,支持node、pm2部署

aotoo dev  # 编译开发环境文件,并打开webpack-dev-server服务

aotoo dev --config test  # 编译测试环境文件,并打开webpack-dev-server服务

aotoo build # 编译生产环境文件

node index.js  # 启动项目,可用于docker

pm2 start index.js # 使用pm2部署项目

配置化

命令行、配置文件相辅相成,一个都不能少,配置能够简化命令行操作,比如需要同时启动多项目,设置某项目的环境等。

下例是hub项目的具体配置项

{
  // 项目名称
  name: 'mdSample', 

  // 指定项目版本
  version: 1.0.8,  

  // 是否启动项目,默认false
  // 启动时,可简化命令行输入项目名称
  startup: true,    

  // 是否启动node server,默认false,由webpack-dev-server提供服务
  // 如在组件开发过程中,关闭node服务,提升性能和效率
  server: false, 

  // 省略前端编译,默认false,需要设置server=true
  // 只启动node端,开发模式提升效率,生产模式可直接部署
  onlynode: false, 

  // 项目源码目录
  src: path.join(ROOT, 'src/mdSample'),  

  // 指定项目端口,不指定则分配随机端口
  port: 8400,

  options: {

    // 项目灰度环境,如测试,预发布,生产等
    // 建议使用命令行 --config test,选择环境配置
    // scenes: 'default' 
  }
},

版本管理

环境与项目隔离

隔离是为了更专注,各司其职,架构师更新环境,开发人员更新业务,互不干扰,编译环境与项目去耦合。使用git能够很方便的实现这一设想,如下图

工作空间  # ==> 设置环境git,忽略src/*
  ├── build
  └── src 
       └─ vueSample # ==> 设置项目git

在使得命令行工具可以支持在项目源码目录中执行,开发人员使用vscode仅仅打开vueSample目录就可以心无旁骛的开始开发工作。

# 在hub工作空间目录下  
aotoo dev vueSample # 运行开发环境

# 在项目vueSample目录下
cd src/vueSample
aotoo dev vueSample # 在项目源码目录中也可以启动开发环境

项目版本
项目版本基于配置文件的version,项目的静态资源会被全部编译至dist/${version}目录,多个版本就会存在多个目录,方便回滚,备份等等保险操作,如下图

├─ dist          # 输出目录(前端)
    │─ 1.0.1     # 版本目录,依据配置中的version字段
    └─ 1.0.8
        └─ dev # 开发目录
            │── js/index.js
            │── html/index.html
             ...

多环境

test1环境,test2环境,test3环境...,脚手架通过命令行参数指定项目当前运行时环境配置,也可以设置配置文件来切换。

现在很多公司都用到了apollo这样的云配置中心,这对于开发者来说非常不方便,有两种方案可以考虑,一是使用命令行传递不同参数,使项目调用云配置或者本地配置;二是在项目初始化时,在配置文件中创建方法去自动抓取云配置。

aotoo dev --config test1

规范项目目录

设计合理、规范、灵活的模板对于项目结构的合理性非常有好处,因为我们都围绕模板来建立目录,产出资源,而任何资源最终都被用在模板上。

模板的静态资源

<html>
 <head>
  <link href="common.css" />
  <link href="[filename].css" />
 </head>
 <body>
 <div id="root"><%- root %></id>
  <script data-original="vendors.js" />
  <script data-original="common.js" />
  <script data-original="[filename].js" />
 </body
</html>
  • common.js 分离的公共JS代码,由webpack编译时提取,注入
  • vendors.js 第三方框架库及通用库,对应js/vendors目录
  • common.css 公共CSS,对应css/common.[css|scss|styl]文件
  • [ filename ].js 业务JS,对应业务目录/文件名称如 user.js
  • [ filename ].css 业务CSS,对应业务目录/文件名称如 user.css
  • hub使用ejs解析模板,由node 或 webpack-dev-server解析模板

如上例模板较为规范,可以很容易输出规范的项目目录结构,大致有如下

 project
    ├── html
         └── index.html
    ├── css
         └── index.css
    └── js
         └── index.js

在hub项目中,大部分情况下我们可以省略html目录和css目录(无额外需求),目录结构可以精简如下

 project
    └── js
         └── index.js

作为hub项目,它可以正常运行,hub在编译时会自动为该项目生成模板、样式、node等等。

仿小程序目录结构
hub项目的目录结构也可以项小程序或者Vue这样设置,如下例

 project
    └── js
         └── user 
              ├── index.js  # 输出业务JS => user.js
              ├── index.css # 输出业务CSS => user.css
              └── index.html # 输出业务模板 => user.html

关于webpack的entry

前面刚说了模板很重要,但却选择了基于JS索引生成webpack的entry,这里有一个假设前提,即每一个模板页面必然包含一个业务JS。

基于JS索引构建的entry对于webpack来说有天然的亲和性,可以使用webpack-html-plugins自动生成其对应的模板。

作为编译入口,我们可以为webpack的entry赋予了更多的职能,为entry的每一个trunk编译其静态资源、生成node端路由、生成node端api等等。

reactSample
hub的演示项目reactSample目录结构非常简单,构成如下

reactSample
     ├── configs/default.js # 环境配置,实际上也是编译时生成的
     └── js/index/index.js

该项目所需的资源文件在编译时自动生成,样式在运行时由JS注入,生产环境提供node服务,开发环境提供dev-server服务。服务端提供模板渲染、镜像路由,镜像API等服务。而这些只是一个命令node index.js就ok了

多页和单页

终极目标当然是实现MPA-SPA混合模式,即多-单页模式,H5需要SPA,PC需要MPA+SPA。MPA很简单,node端输出的页面都是独立的页面,有模板就行,是天然的MPA模式。

hub工作空间
  ├── mdSample
       ├─ dir1 # => page1.html
       ├─ dir2 # => page2.html
       └─ dir3 # => page3.html

hub也能够很容易的为react,vue提供MPA-SPA模式,这是hub脚手架自带的buf

Node中间层

引入node端可以解决

  • 跨域
  • 独立部署
  • 自定义API
  • mock服务
  • seo

路由

镜像路由
通过webpack的entry构建的node路由,称之为镜像路由,镜像路由可以提供页面渲染服务和API数据接口服务

镜像路由示意结构

reactSample
     └── js
          └── user/index.js # 前端
     └── server
          └── pages/user/index.js # node端

# 访问镜像路由  http://www.abc.com/user
# 请求API数据路由  ajax.post('/user', {...}).then(res)  
# node端controler文件  server/pages/user/index.js  

我们知道koa2是典型的MVC后端模型,view对应模板,model对应数据层(由业务驱动,大部分是ajax数据)。将entry的trunk与koa-router绑定,就构成与前端的镜像路由关系(一一对应),同时输出其controler文件用来处理GET/POST/PUT/DELETE等事务

自定义路由
有些路由与页面没有关系,比如上传,API接口就属于这一类特殊的路由,所以我们还需要通过plugins来手动创建自定义的路由

插件

插件用来创建自定义路由与创建功能模块,自定义路由如上所述。

功能模块的典型应用是数据库请求。controler层一般都需要发起数据库请求,插件将功能模块绑定到koa的context上。注意这个过程是一个预处理过程,而controler层的运行则属于运行时过程,因此只要controler层有需要就可以从koa的上下文中获得数据处理模块用来发起数据库请求

小程序支持

对于小程序项目,我们要控制webpack输出,不要让它做多余的事情,最终输出代码由微信小程序开发工具来处理

按照1:1对等输出小程序源码,仅利用webpack处理一下小程序的一些小毛病,比如markdown,动态样式,html等等。(输出不会产生webpack的冗余代码),支持热更新

使用webpack参与小程序编译,我们可以使用loader/plugins实现一些想法,比如写个loader,将微信小程序转译成其他小程序,只是对照表有点麻烦,思路应该是可行的

兼容老旧项目

我也是个前端老人了,前端切图仔的岁月依稀在眼前,前端后端的融合开发模式(java渲染模板)痛点在于你要搭一个java环境,你需要知道模板中的每一个ID,你对模板庞杂的结构了若指掌。

在脚手架编译的同时,将所有资源收集起来并整理一份资源文件(mapfile.json),包含JS/HTML/CSS/IMAGE等信息。将mapfile.json交给java大佬,将静态资源文件目录(dist)交给运维大哥,请求java大佬写一份脚本自动匹配静态资源。

自带的演示项目

项目包含(REACT/VUE/小程序/MD 文档系统)等 4 套 DEMO 演示项目,每个项目即是独立项目,又能够通过node端实现资源互通。方便快速上手各套环境。

Vue 演示 DEMO

vueSample,基于vue2.0 的空项目,包含vue-router简单项目演示

vue的演示项目没有上vue3.0,因为开发时vue3.0周边库还没有稳定,打算让它再飞一会,你也可以自行引入,更多说明

REACT 演示 DEMO

reactSample,一个空项目,包含REACT16 的简单演示。

小程序演示 DEMO

xcxSample,是原生微信小程序开发,支持云开发,该项目包含我们开发的小程序库queryUI库及相关组件,清空相关引用就是一个完整的小程序官方示例代码。更多说明

  • 动态样式支持
  • webpack的各种插件支持
  • 原生微信小程序
  • 动态模板构建
  • template less
  • 支持钩子方法
  • 内置支持 MARKDOWN 富文本
  • 内置支持 HTML 富文本
  • 由aotoo库构建

MD文档系统

这里重点讲一下文档系统,文档系统是一套hub环境的完整演示,包含前端编译,路由,NODE 端镜像路由、插件,MPA/SPA应用,命令行的使用等等,更多说明

  • 全栈系统
  • 栏目首页由 node.js 做服务端渲染 (SSR),浏览器端复用后渲染 (CSR & SPA)
  • 栏目项是多页应用(MPA)切换页面;菜单项是单页应用(SPA)切换路由
  • 自定义同构方法,Fether/requireMarkdown等,Fetcher前端axios构建,node端got构建
  • 仿小程序的前端路由,构建时可以根据路由切割代码,按需加载 js 文件,支持SSR
  • 仿小程序生命周期,让page代码、component代码有更清晰的功能划分
  • template less,尽可能的少写模板,将模板逻辑交由JS实现(aotoo库的核心功能)
  • 支持 markdown
  • 支持 SEO,内部自动解决在浏览器端复用服务端渲染的 html 和数据,无缝过渡
  • 多场景化,测试,生产一应俱全
  • aotoo库构建(react的封装库,react组件jquery化)(说明)

目录结构

image

aotoo-hub安装完成后的目录结构

hub工作空间
  ├── build
  ├── aotoo.config.js
  ├── index.js
  ├── package.json
  ├── postcss.config.js
  └── src
       # vue 项目演示
       └─ vueSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # react 项目演示
       └─ reactSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # 小程序项目演示
       └─ xcxSample 
             ...
             ...
             
       # 文档项目演示
       └─ mdSample 
             ...
             ...

说明

aotoo.config.js

项目配置文件,包含项目版本,项目类型,项目源码目录等各种配置

apps: [
    {
      name: 'reactSample', // 项目名称
      version: '1.0.1',   // 项目版本,每个项目有自己的版本
      startup: true,      // 是否默认启动
      server: false,      // 是否提供node端服务,默认为`dev-server`提供服务
      type: 'web',        // 项目类型, 'mp'为小程序
      src: path.join(ROOT, 'src/reactSample'), // 源码目录
      micro: false,       // 是否微服务模式(开发中,未完善)
      options: {          // 项目扩展参数
        scenes: 'default',// 默认项目环境,将生成环境配置文件 
        cloud: false,     // 小程序项目是否启动云端
      },
      port: 8500          // 项目端口
    },
    
    {
      name: 'vueSample',
      ...
    },
    
    {
      name: 'xcxSample',
      ...
    }
  ],
}

configs目录

该目录存放环境配置,编译器会根据命令行所给出的环境参数生成相关的配置,比如测试环境(test=>test.js)

aotoo dev --config test

dist目录

该目录存放前端编译文件,包含版本目录,开发目录,生产目录

├─ dist          # 输出目录(前端)
    │─ 1.0.1     # 版本目录,依据配置中的version字段
    └─ 1.0.8
        └─ dev # 开发目录
            │── js/index.js
            │── html/index.html
             ...
            └── mapfile.json # 静态资源镜像文件

        └─ pro # 生产目录
            │── js/index_[hash].js # 生产文件会带上hash
             ...
            └── mapfile.json # 静态资源镜像文件

js目录

该目录存放前端JS源码,公共JS源码,业务JS源码

├─ js         
    │─ vendors # 公共JS
    └─ index # 前端业务目录
         │─ index.js  # 业务 => dist/.../index.js
         │─ index.scss # 样式 => dist/.../index.css
         └─ index.html # 模板 => dist/.../index.html
         
    └─ shop # 前端业务目录
         │─ index.js  # 业务 => dist/.../shop.js
         │─ index.scss # 样式 => dist/.../shop.css
         └─ index.html # 模板 => dist/.../shop.html

编译思路
编译器遍历业务JS文件并生成其配套资源(html/css/node)

dll打包(vendors目录)
这里dll打包指代打包vendors.jscommon.js,需要注意的是,vendors是一套独立的webpack打包进程,与业务JS打包不在一个进程,将生成dist/.../vendors.js文件,common.js来自业务JS的分离代码(webpack的split)。其中vendors.js文件不支持热更新

业务打包
业务打包以JS文件作为webpack的entry,通过loader模块同时生成其相关静态资源,如上例的shop目录

js/vendors目录

该目录存放通用代码或者框架库,比如在vendors/index.js中引入react或者vue,需要注意,该目录的内容会被应用到node端(如果启用了server服务),所以一定要注意两端的兼容性写法

server目录

当你在配置中设置server参数为true时,编译器将会自动生成server目录,并将后端服务由webpack-dev-server转向由node端提供的服务

镜像路由server/pages
当你在配置中设置server参数为true时,编译器会遍历entry(业务JS),并镜像生成其node端的controler文件,如上例shop

server
  └─ pages
      └─ shop
          └─ index.js
controler文件默认提供渲染服务、GET、POST接口服务

node端插件server/plugins
该目录需要手动生成,插件支持自定义路由,支持自定义功能模块

命令行工具

aotoo-cli是aotoo-hub的命令行开发工具库,安装、编译都需要通过此工具来操作更多说明

系统环境

  • mac osx
  • linux
  • 不支持windows,但 win10 的 linux 子系统可以
  • node >= 12

INSTALL

npm install -g aotoo-cli
aotoo -V # 检查是否安装成功

USAGE

安装一个新的hub空间

init <dir>

# 新建xxx项目
$ aotoo init xxx

新建web类项目

create <dir>

自动创建pc/h5/公众号等web项目并启动项目

# 新建项目
$ cd xxx
$ aotoo create projectName
将会在xxx/src目录下,创建项目目录projectName,按照提示输Y,回车继续
完成安装后,修改aotoo.config.js,补充项目描述

新建小程序项目

create <dir>

适用于创建小程序项目

# 新建项目
$ cd xxx
$ aotoo create minip_name
将会在xxx/src目录下,创建项目目录minip_name,提示请输入n
完成安装后,打开aotoo.config.js,补充项目描述,重新启动项目

启动自带项目

安装完成后,hub的src目录下包含4个演示项目,通过以下命令可以分别启动

启动文档项目

# 文档项目属于默认项目,可以直接启动
$ aotoo dev

启动VUE项目

$ aotoo dev vueSample

启动REACT项目

$ aotoo dev reactSample

启动小程序项目

编译完成后需要使用小程序开发工具打开终端黄色字体指示的目录

$ aotoo dev xcxSample

工作模式

dev [name]

开发模式编译,并启动服务,前端支持热更新

# 启动aotoo.config中apps中的启动项目
$ aotoo dev

# 启动指定项目
$ aotoo dev project_name

dev-clean [name]

开发模式编译,清除common/vendors等dll文件,重新生成,并启动服务

$ aotoo dev-clean

# 启动指定项目,并清除该项目common的缓存
$ aotoo dev-clean project_name

dev-build [name]

开发模式编译,清除common/vendors等dll文件,重新生成,不启动服务

$ aotoo dev-build

# 编译指定项目
$ aotoo dev-build project_name

生产模式

build [name]

生产模式,纯编译输出,不启动服务

$ aotoo build

# 编译指定项目
$ aotoo build project_name

build-clean [name]

生产模式,清除common/vendors等dll文件,并重新生成,不启动服务

$ aotoo build-clean

# 编译指定项目
$ aotoo build-clean project_name

部署

部署之前,需要先以生产模式编译

aotoo build-clean project_name

start [name]

生产模式,启动node服务

$ aotoo start

# 编译指定项目
$ aotoo start project_name

node启动

# 编译项目,生产模式
$ node index.js

# 启动测试环境
$ node index.js --config test

# 进入源码server目录启动  
$ cd src/project_name/server
$ node index.js

pm2启动

# 编译项目,生产模式
$ aotoo build-clean project_name

# 启动测试环境
$ pm2 start index.js -- --config test

EXTEND PARAM 扩展参数

--config <name>

指定环境配置文件,配合node端一起使用

# 开发模式,启动测试环境
aotoo dev --config test

# 生产模式,启动测试环境  
aotoo build --config test

--name <name>

启动指定项目,该参数可多项目同时启动

# 同时启动 xxx 及 yyy的项目
$ aotoo dev --name vueDemo --name reactDemo

GITHUB
更多说明

查看原文

赞 0 收藏 0 评论 0

天天修改 发布了文章 · 2020-12-25

webpack编译vue样式 "export 'default' (imported as 'mod')错误

最近升级了脚手架的编译层的代码,vue同学反馈过来说编译样式出现警告,记录下问题及解决方法

错误信息

WARNING in ../vueSample/js/index/_pages/app.vue?vue&type=style&index=0&lang=stylus& 1:469-472
"export 'default' (imported as 'mod') was not found in '-!../../../../../node_modules/mini-css....

问题webpack配置

{
  test: /\.styl(us)?$/,
  use: envAttributs('styl', [
    'stylus-loader'
  ])
},

// 辅助方法 
function envAttributs(param){
  return ([
    {
      loader: MiniCssExtractPlugin.loader,
      options: {
        publicPath: domain,
      }
    },
    {
      loader: 'css-loader',
      options: {
        importLoaders: 2,
        url: false
      }
    },
    'postcss-loader',
  ]).concat(param)
}

问题解决

经网上多番搜索,定位到问题出在MiniCssExtractPlugin.loader。上一次为了解决样式背景图片不输出的问题,修改过MiniCssExtractPlugin.loader的配置

options: {
  publicPath: domain, // domain域名指向开发服务,解决css中url图片定位的问题
}

开启参数设置后导致vue样式编译提示警告信息,后来通过查阅通过MiniCssExtractPlugin官方文档了解到MiniCssExtractPlugin.esModule属性默认为true。而webpack4开启tree shaking功能也需要设置esModule为true,但正是该参数导致vue2编译样式时出错。修改参数配置

options: {
  publicPath: domain, // domain域名指向开发服务
  esModule: false,  // 将此属性设为false,问题解决
}

esModule
esModule即ES MODULE语法,支持编译时加载,不同于CommonJS运行时加载。在编译时就能够确定模块的依赖关系,以及输入和输出的变量。ESM可以做到tree shaking。可以只加载模块的部分内容

源码

查看原文

赞 0 收藏 0 评论 0

天天修改 关注了用户 · 2020-12-25

China @zlwd

多吃些学习的苦,少受些生活的累。

关注 1

天天修改 赞了文章 · 2020-12-24

Webpack Loader 高手进阶(一)

文章首发于个人github blog: Biu-blog,欢迎大家关注~

Webpack 系列文章:

Webpack Loader 高手进阶(一)
Webpack Loader 高手进阶(二)
Webpack Loader 高手进阶(三)


Webpack loader 详解

loader 的配置

Webpack 对于一个 module 所使用的 loader 对开发者提供了2种使用方式:

  1. webpack config 配置形式,形如:
// webpack.config.js
module.exports = {
  ...
  module: {
    rules: [{
      test: /.vue$/,
      loader: 'vue-loader'
    }, {
      test: /.scss$/,
      use: [
        'vue-style-loader',
        'css-loader',
        {
          loader: 'sass-loader',
          options: {
            data: '$color: red;'
          }
        }
      ]
    }]
  }
  ...
}
  1. inline 内联形式
// module

import a from 'raw-loader!../../utils.js'

2种不同的配置形式,在 webpack 内部有着不同的解析方式。此外,不同的配置方式也决定了最终在实际加载 module 过程中不同 loader 之间相互的执行顺序等。

loader 的匹配

在讲 loader 的匹配过程之前,首先从整体上了解下 loader 在整个 webpack 的 workflow 过程中出现的时机。

图片描述

在一个 module 构建过程中,首先根据 module 的依赖类型(例如 NormalModuleFactory)调用对应的构造函数来创建对应的模块。在创建模块的过程中(new NormalModuleFactory()),会根据开发者的 webpack.config 当中的 rules 以及 webpack 内置的 rules 规则实例化 RuleSet 匹配实例,这个 RuleSet 实例在 loader 的匹配过滤过程中非常的关键,具体的源码解析可参见Webpack Loader Ruleset 匹配规则解析。实例化 RuleSet 后,还会注册2个钩子函数:

class NormalModuleFactory {
  ...
  // 内部嵌套 resolver 的钩子,完成相关的解析后,创建这个 normalModule
  this.hooks.factory.tap('NormalModuleFactory', () => (result, callback) => { ... })

  // 在 hooks.factory 的钩子内部进行调用,实际的作用为解析构建一共 module 所需要的 loaders 及这个 module 的相关构建信息(例如获取 module 的 packge.json等)
  this.hooks.resolver.tap('NormalModuleFactory', () => (result, callback) => { ... })
  ...
}

当 NormalModuleFactory 实例化完成后,并在 compilation 内部调用这个实例的 create 方法开始真实开始创建这个 normalModule。首先调用hooks.factory获取对应的钩子函数,接下来就调用 resolver 钩子(hooks.resolver)进入到了 resolve 的阶段,在真正开始 resolve loader 之前,首先就是需要匹配过滤找到构建这个 module 所需要使用的所有的 loaders。首先进行的是对于 inline loaders 的处理:

// NormalModuleFactory.js

// 是否忽略 preLoader 以及 normalLoader
const noPreAutoLoaders = requestWithoutMatchResource.startsWith("-!");
// 是否忽略 normalLoader
const noAutoLoaders =
  noPreAutoLoaders || requestWithoutMatchResource.startsWith("!");
// 忽略所有的 preLoader / normalLoader / postLoader
const noPrePostAutoLoaders = requestWithoutMatchResource.startsWith("!!");

// 首先解析出所需要的 loader,这种 loader 为内联的 loader
let elements = requestWithoutMatchResource
  .replace(/^-?!+/, "")
  .replace(/!!+/g, "!")
  .split("!");
let resource = elements.pop(); // 获取资源的路径
elements = elements.map(identToLoaderRequest); // 获取每个loader及对应的options配置(将inline loader的写法变更为module.rule的写法)

首先是根据模块的路径规则,例如模块的路径是以这些符号开头的 ! / -! / !! 来判断这个模块是否只是使用 inline loader,或者剔除掉 preLoader, postLoader 等规则:

  • ! 忽略 webpack.config 配置当中符合规则的 normalLoader
  • -! 忽略 webpack.config 配置当中符合规则的 preLoader/normalLoader
  • !! 忽略 webpack.config 配置当中符合规则的 postLoader/preLoader/normalLoader

这几个匹配规则主要适用于在 webpack.config 已经配置了对应模块使用的 loader,但是针对一些特殊的 module,你可能需要单独的定制化的 loader 去处理,而不是走常规的配置,因此可以使用这些规则来进行处理。

接下来将所有的 inline loader 转化为数组的形式,例如:

import 'style-loader!css-loader!stylus-loader?a=b!../../common.styl'

最终 inline loader 统一格式输出为:

[{
  loader: 'style-loader',
  options: undefined
}, {
  loader: 'css-lodaer',
  options: undefined
}, {
  loader: 'stylus-loader',
  options: '?a=b'
}]

对于 inline loader 的处理便是直接对其进行 resolve,获取对应 loader 的相关信息:

asyncLib.parallel([
  callback => 
    this.resolveRequestArray(
      contextInfo,
      context,
      elements,
      loaderResolver,
      callback
    ),
  callback => {
    // 对这个 module 进行 resolve
    ...
    callack(null, {
      resouceResolveData, // 模块的基础信息,包含 descriptionFilePath / descriptionFileData 等(即 package.json 等信息)
      resource // 模块的绝对路径
    })
  }
], (err, results) => {
  const loaders = results[0] // 所有内联的 loaders
  const resourceResolveData = results[1].resourceResolveData; // 获取模块的基本信息
  resource = results[1].resource; // 模块的绝对路径
  ...
  
  // 接下来就要开始根据引入模块的路径开始匹配对应的 loaders
  let resourcePath =
    matchResource !== undefined ? matchResource : resource;
  let resourceQuery = "";
  const queryIndex = resourcePath.indexOf("?");
  if (queryIndex >= 0) {
    resourceQuery = resourcePath.substr(queryIndex);
    resourcePath = resourcePath.substr(0, queryIndex);
  }
  // 获取符合条件配置的 loader,具体的 ruleset 是如何匹配的请参见 ruleset 解析(https://github.com/CommanderXL/Biu-blog/issues/30)
  const result = this.ruleSet.exec({
    resource: resourcePath, // module 的绝对路径
    realResource:
      matchResource !== undefined
        ? resource.replace(/\?.*/, "")
        : resourcePath,
    resourceQuery, // module 路径上所带的 query 参数
    issuer: contextInfo.issuer, // 所解析的 module 的发布者
    compiler: contextInfo.compiler 
  });

  // result 为最终根据 module 的路径及相关匹配规则过滤后得到的 loaders,为 webpack.config 进行配置的
  // 输出的数据格式为:

  /* [{
    type: 'use',
    value: {
      loader: 'vue-style-loader',
      options: {}
    },
    enforce: undefined // 可选值还有 pre/post  分别为 pre-loader 和 post-loader
  }, {
    type: 'use',
    value: {
      loader: 'css-loader',
      options: {}
    },
    enforce: undefined
  }, {
    type: 'use',
    value: {
      loader: 'stylus-loader',
      options: {
        data: '$color red'
      }
    },
    enforce: undefined 
  }] */

  const settings = {};
  const useLoadersPost = []; // post loader
  const useLoaders = []; // normal loader
  const useLoadersPre = []; // pre loader
  for (const r of result) {
    if (r.type === "use") {
      // postLoader
      if (r.enforce === "post" && !noPrePostAutoLoaders) {
        useLoadersPost.push(r.value);
      } else if (
        r.enforce === "pre" &&
        !noPreAutoLoaders &&
        !noPrePostAutoLoaders
      ) {
        // preLoader
        useLoadersPre.push(r.value);
      } else if (
        !r.enforce &&
        !noAutoLoaders &&
        !noPrePostAutoLoaders
      ) {
        // normal loader
        useLoaders.push(r.value);
      }
    } else if (
      typeof r.value === "object" &&
      r.value !== null &&
      typeof settings[r.type] === "object" &&
      settings[r.type] !== null
    ) {
      settings[r.type] = cachedMerge(settings[r.type], r.value);
    } else {
      settings[r.type] = r.value;
    }

    // 当获取到 webpack.config 当中配置的 loader 后,再根据 loader 的类型进行分组(enforce 配置类型)
    // postLoader 存储到 useLoaders 内部
    // preLoader 存储到 usePreLoaders 内部
    // normalLoader 存储到 useLoaders 内部
    // 这些分组最终会决定加载一个 module 时不同 loader 之间的调用顺序

    // 当分组过程进行完之后,即开始 loader 模块的 resolve 过程
    asyncLib.parallel([
      [
        // resolve postLoader
        this.resolveRequestArray.bind(
          this,
          contextInfo,
          this.context,
          useLoadersPost,
          loaderResolver
        ),
        // resove normal loaders
        this.resolveRequestArray.bind(
          this,
          contextInfo,
          this.context,
          useLoaders,
          loaderResolver
        ),
        // resolve preLoader
        this.resolveRequestArray.bind(
          this,
          contextInfo,
          this.context,
          useLoadersPre,
          loaderResolver
        )
      ],
      (err, results) => {
        ...
        // results[0]  ->  postLoader
        // results[1]  ->  normalLoader
        // results[2]  ->  preLoader
        // 这里将构建 module 需要的所有类型的 loaders 按照一定顺序组合起来,对应于:
        // [postLoader, inlineLoader, normalLoader, preLoader]
        // 最终 loader 所执行的顺序对应为: preLoader -> normalLoader -> inlineLoader -> postLoader
        // 不同类型 loader 上的 pitch 方法执行的顺序为: postLoader.pitch -> inlineLoader.pitch -> normalLoader.pitch -> preLoader.pitch (具体loader内部执行的机制后文会单独讲解)
        loaders = results[0].concat(loaders, results[1], results[2]);

        process.nextTick(() => {
          ...
          // 执行回调,创建 module
        })
      }
    ])
  }
})

简单总结下匹配的流程就是:

首先处理 inlineLoaders,对其进行解析,获取对应的 loader 模块的信息,接下来利用 ruleset 实例上的匹配过滤方法对 webpack.config 中配置的相关 loaders 进行匹配过滤,获取构建这个 module 所需要的配置的的 loaders,并进行解析,这个过程完成后,便进行所有 loaders 的拼装工作,并传入创建 module 的回调中。


文章首发于个人github blog: Biu-blog,欢迎大家关注~

查看原文

赞 29 收藏 24 评论 2

天天修改 发布了文章 · 2020-12-21

webpack解决样式url()背景图问题

定位错误

项目使用webpack4进行编译,打包后的样式中使用url方法指定的背景图片不能够正常显示

.bgurl{
  background-image: url('/images/abc.jpeg')
}

上述样式在webpack中编译出错,配置如下

// stylus
[
  loader: MiniCssExtractPlugin.loader,
  {
    loader: 'css-loader',
    options: {
      importLoaders: 2,
    }
  },
  'postcss-loader',
  'stylus-loader'
]

始终爆图片未找到的错误,按照网上找到的解决方案,补全MiniCssExtractPlugin配置项publicPath,但依然出错。看来问题不是出在MiniCssExtractPlugin。后经过仔细排查。定位到错误其实是css-loader编译时爆出来的。

去npm上找到css-loader配置,找到如下内容

NameTypeDefaultDescription
url{Boolean\Function}trueEnables/Disables url/image-set functions handling

url
Type: Boolean|Function Default: true

Enables/Disables url/image-set functions handling. Control url() resolving. Absolute URLs are not resolving.

Examples resolutions:

url(image.png) => require('./image.png')
url('image.png') => require('./image.png')
url(./image.png) => require('./image.png')
url('./image.png') => require('./image.png')
url('http://dontwritehorriblecode.com/2112.png') => require('http://dontwritehorriblecode.com/2112.png')
image-set(url('image2x.png') 1x, url('image1x.png') 2x) => require('./image1x.png') and require('./image2x.png')

属性url默认为true,此属性默认强行解释样式中使用url定位的图片,默认寻址该图片地址(与css同源地址),因同源地址图片不存在(实际图片存在于另外的目录),造成编译错误。

修正webpack配置

[
  {
    loader: MiniCssExtractPlugin.loader,
    options: {
      publicPath: '//localhost:3000/', 
    }
  },
  {
    loader: 'css-loader',
    options: {
      importLoaders: 2,
      url: false 
    }
  },
  'postcss-loader',
  'stylus-loader'
]

publicPath
此项配置可以共用webpack.output配置,但建议在MiniCssExtractPlugin.loader中独立设置。有以下两个原因

  1. css的url(...)中的图片一般是相对路径寻址图片
  2. 运维会提供静态资源服务器,图片有专属的域名地址

url
css-loader的配置url设为false,使css-loader不解析样式中url(...)的图片地址,保持原有属性

至此,webpack打包样式url()背景图片问题得以解决,希望对大家有帮助

开源项目

查看原文

赞 0 收藏 0 评论 0

天天修改 发布了文章 · 2020-12-20

2020成绩单:前端脚手架的开发思路与实现

在一个小公司最大的好处是什么都要搞,PC/公众号/小程序,前台,后台,中台,react,vue、UI都接触,产品需求不断,项目越做越多,人还是那几个。对于前端的标准化,工程化的要求越来越高。

aotoo-hub作为一套通用型前端脚手架,无技术栈依赖,聚焦于多人协作及工程化。可以帮助开发者快速产出项目,喜欢折腾的可以研究下。

aotoo-hub是一套前端/NODE 端一体化设计的全栈脚手架,前端使用webpack4编译,node端使用koa2提供服务。hub可独立运行作为前端编译工具,也可配合node端部署线上服务

独立运行时,仅作为前端编译、输出静态资源工具,hub采用webpack对前端的资源进行编译,开发模式下由webpack-dev-server提供热更新支持,生产环境下仅产出压缩后的代码

融合运行时,node(koa2,koa-router)将接管webpack-dev-server提供后端服务,此时可实现SSR服务,API服务,可用于部署,提供线上服务

一些特点

  • 提供简单的命令行工具
  • 编译环境支持多项目,共享编译资源及node_module
  • 支持为React/Vue/Jq/原生js/小程序等项目提供编译环境
  • 规范的前端、node端目录结构
  • 支持动态样式(sass/stylus)
  • 支持多环境,命令行切换测试,生产等环境
  • 支持node端(koa2)

脚手架源码结构

hub工作空间
  ├── build
  ├── aotoo.config.js
  ├── index.js
  ├── package.json
  ├── postcss.config.js
  └── src
       # vue 项目演示
       └─ vueSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # react 项目演示
       └─ reactSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # 小程序项目演示
       └─ xcxSample 
             ...
             ...
             
       # 文档项目演示
       └─ mdSample 
             ...
             ...

GITHUB
更多说明

hub脚手架开发的一些思路

前端项目架构比后端项目架构更具有挑战性,为啥呢?一般后端架构(大部分中小项目)稳定在一个环境,语言,项目下,可能几年更新一次,而前端要应对多端输出,项目繁杂,多种框架,复杂组件等等情况,框架的更新还特别活跃,经常有学不动想放弃的感觉。

脚手架作为一个重要前端工具,特别需要有统一的,标准化的思想。好的脚手架能够能让开发,测试、运维的工作变得很爽。我们需要脚手架能约束、规范工程项目结构;有统一的编译环境;与项目去耦合;方便进行多人协作;还应该要简单,开箱即用,开发者只需关注业务,在生成的项目结构的基础上进行开发即可

与项目解耦

为什么需要将架构与项目去耦合,试想下,当一个公司存在n个项目时,架构师更新、维护不同项目,不同框架的脚手架,造成项目不稳定的风险,同时增加了架构师、开发人员、测试人员、运维人员,运营人员的时间成本,沟通成本。

技术栈无关

公司项目类型很多,轻量,且需要快速迭代上线的项目,一般我们用vue;比较复杂,由多人协作共同完成的项目。一般我们用react;小程序也是前端的热门方向,还有老旧基于jq项目,因此脚手架需要能够支持编译多种前端框架

这个不是很难,在webpack中添加各种框架所需的配置就可以实现,hub目前支持React、Vue、Angular、Jquery、小程序等技术栈。

多项目支持

架构与项目去耦合,即可单个项目独立编译运行,又可以同时运行。所有项目共享hub工作空间的编译环境,共享工作空间的node_module。项目自有dist目录,启动时有分配唯一服务端口。如下图所示

工作空间
  ├── build
  └── src
       └─ vueSample
             └─ dist
       └─ reactSample
                └─ dist
       └─ mdSample
             └─ dist
       └─ xcxSample
             └─ dist

命令行工具

命令行需要简洁高效,能够实现环境初始化、项目初始化、开发模式、生产模式,环境切换,可以传递参数,支持一键部署

配置化

命令行、配置文件相辅相成,一个都不能少,配置能够简化命令行操作,比如需要同时启动多项目,设置某项目的环境等。

下例是hub项目的具体配置项

{
  // 项目名称
  name: 'mdSample', 

  // 指定项目版本
  version: 1.0.8,  

  // 是否启动项目,默认false
  // 启动时,可简化命令行输入项目名称
  startup: true,    

  // 是否启动node server,默认false,由webpack-dev-server提供服务
  // 如在组件开发过程中,关闭node服务,提升性能和效率
  server: false, 

  // 省略前端编译,默认false,需要设置server=true
  // 只启动node端,开发模式提升效率,生产模式可直接部署
  onlynode: false, 

  // 项目源码目录
  src: path.join(ROOT, 'src/mdSample'),  

  // 指定项目端口,不指定则分配随机端口
  port: 8400,

  options: {

    // 项目灰度环境,如测试,预发布,生产等
    // 建议使用命令行 --config test,选择环境配置
    // scenes: 'default' 
  }
},

版本管理

环境与项目隔离

隔离是为了更专注,各司其职,架构师更新环境,开发人员更新业务,互不干扰,编译环境与项目去耦合。使用git能够很方便的实现这一设想,如下图

工作空间  # ==> 设置环境git,忽略src/*
  ├── build
  └── src 
       └─ vueSample # ==> 设置项目git

在使得命令行工具可以支持在项目源码目录中执行,开发人员使用vscode仅仅打开vueSample目录就可以心无旁骛的开始开发工作。

# 在hub工作空间目录下  
aotoo dev vueSample # 运行开发环境

# 在项目vueSample目录下
cd src/vueSample
aotoo dev vueSample # 在项目源码目录中也可以启动开发环境

项目版本
项目版本基于配置文件的version,项目的静态资源会被全部编译至dist/${version}目录,多个版本就会存在多个目录,方便回滚,备份等等保险操作,如下图

├─ dist          # 输出目录(前端)
    │─ 1.0.1     # 版本目录,依据配置中的version字段
    └─ 1.0.8
        └─ dev # 开发目录
            │── js/index.js
            │── html/index.html
             ...

多环境

test1环境,test2环境,test3环境...,脚手架通过命令行参数指定项目当前运行时环境配置,也可以设置配置文件来切换。

现在很多公司都用到了apollo这样的云配置中心,这对于开发者来说非常不方便,有两种方案可以考虑,一是使用命令行传递不同参数,使项目调用云配置或者本地配置;二是在项目初始化时,在配置文件中创建方法去自动抓取云配置。

aotoo dev --config test1

规范项目目录

设计合理、规范、灵活的模板对于项目结构的合理性非常有好处,因为我们都围绕模板来建立目录,产出资源,而任何资源最终都被用在模板上。

模板的静态资源

<html>
 <head>
  <link href="common.css" />
  <link href="[filename].css" />
 </head>
 <body>
 <div id="root"><%- root %></id>
  <script data-original="vendors.js" />
  <script data-original="common.js" />
  <script data-original="[filename].js" />
 </body
</html>
  • common.js 分离的公共JS代码,由webpack编译时提取,注入
  • vendors.js 第三方框架库及通用库,对应js/vendors目录
  • common.css 公共CSS,对应css/common.[css|scss|styl]文件
  • [ filename ].js 业务JS,对应业务目录/文件名称如 user.js
  • [ filename ].css 业务CSS,对应业务目录/文件名称如 user.css
  • hub使用ejs解析模板,由node 或 webpack-dev-server解析模板

如上例模板较为规范,可以很容易输出规范的项目目录结构,大致有如下

 project
    ├── html
         └── index.html
    ├── css
         └── index.css
    └── js
         └── index.js

在hub项目中,大部分情况下我们可以省略html目录和css目录(无额外需求),目录结构可以精简如下

 project
    └── js
         └── index.js

作为hub项目,它可以正常运行,hub在编译时会自动为该项目生成模板、样式、node等等。

仿小程序目录结构
hub项目的目录结构也可以项小程序或者Vue这样设置,如下例

 project
    └── js
         └── user 
              ├── index.js  # 输出业务JS => user.js
              ├── index.css # 输出业务CSS => user.css
              └── index.html # 输出业务模板 => user.html

关于webpack的entry

前面刚说了模板很重要,但却选择了基于JS索引生成webpack的entry,这里有一个假设前提,即每一个模板页面必然包含一个业务JS。

基于JS索引构建的entry对于webpack来说有天然的亲和性,可以使用webpack-html-plugins自动生成其对应的模板。

作为编译入口,我们可以为webpack的entry赋予了更多的职能,为entry的每一个trunk编译其静态资源、生成node端路由、生成node端api等等。

reactSample
hub的演示项目reactSample目录结构非常简单,构成如下

reactSample
     ├── configs/default.js # 环境配置,实际上也是编译时生成的
     └── js/index/index.js

该项目所需的资源文件在编译时自动生成,样式在运行时由JS注入,生产环境提供node服务,开发环境提供dev-server服务。服务端提供模板渲染、镜像路由,镜像API等服务。而这些只是一个命令node index.js就ok了

多页和单页

终极目标当然是实现MPA-SPA混合模式,即多-单页模式,H5需要SPA,PC需要MPA+SPA。MPA很简单,node端输出的页面都是独立的页面,有模板就行,是天然的MPA模式。

hub工作空间
  ├── mdSample
       ├─ dir1 # => page1.html
       ├─ dir2 # => page2.html
       └─ dir3 # => page3.html

hub也能够很容易的为react,vue提供MPA-SPA模式,这是hub脚手架自带的buf

Node中间层

引入node端可以解决

  • 跨域
  • 独立部署
  • 自定义API
  • mock服务
  • seo

路由

镜像路由
通过webpack的entry构建的node路由,称之为镜像路由,镜像路由可以提供页面渲染服务和API数据接口服务

镜像路由示意结构

reactSample
     └── js
          └── user/index.js # 前端
     └── server
          └── pages/user/index.js # node端

# 访问镜像路由  http://www.abc.com/user
# 请求API数据路由  ajax.post('/user', {...}).then(res)  
# node端controler文件  server/pages/user/index.js  

我们知道koa2是典型的MVC后端模型,view对应模板,model对应数据层(由业务驱动,大部分是ajax数据)。将entry的trunk与koa-router绑定,就构成与前端的镜像路由关系(一一对应),同时输出其controler文件用来处理GET/POST/PUT/DELETE等事务

自定义路由
有些路由与页面没有关系,比如上传,API接口就属于这一类特殊的路由,所以我们还需要通过plugins来手动创建自定义的路由

插件

插件用来创建自定义路由与创建功能模块,自定义路由如上所述。

功能模块的典型应用是数据库请求。controler层一般都需要发起数据库请求,插件将功能模块绑定到koa的context上。注意这个过程是一个预处理过程,而controler层的运行则属于运行时过程,因此只要controler层有需要就可以从koa的上下文中获得数据处理模块用来发起数据库请求

小程序支持

对于小程序项目,我们要控制webpack输出,不要让它做多余的事情,最终输出代码由微信小程序开发工具来处理

按照1:1对等输出小程序源码,仅利用webpack处理一下小程序的一些小毛病,比如markdown,动态样式,html等等。(输出不会产生webpack的冗余代码),支持热更新

使用webpack参与小程序编译,我们可以使用loader/plugins实现一些想法,比如写个loader,将微信小程序转译成其他小程序,只是对照表有点麻烦,思路应该是可行的

兼容老旧项目

我也是个前端老人了,前端切图仔的岁月依稀在眼前,前端后端的融合开发模式(java渲染模板)痛点在于你要搭一个java环境,你需要知道模板中的每一个ID,你对模板庞杂的结构了若指掌。

在脚手架编译的同时,将所有资源收集起来并整理一份资源文件(mapfile.json),包含JS/HTML/CSS/IMAGE等信息。将mapfile.json交给java大佬,将静态资源文件目录(dist)交给运维大哥,请求java大佬写一份脚本自动匹配静态资源。

自带的演示项目

项目包含(REACT/VUE/小程序/MD 文档系统)等 4 套 DEMO 演示项目,每个项目即是独立项目,又能够通过node端实现资源互通。方便快速上手各套环境。

Vue 演示 DEMO

vueSample,基于vue2.0 的空项目,包含vue-router简单项目演示

vue的演示项目没有上vue3.0,因为开发时vue3.0周边库还没有稳定,打算让它再飞一会,你也可以自行引入,更多说明

REACT 演示 DEMO

reactSample,一个空项目,包含REACT16 的简单演示。

小程序演示 DEMO

xcxSample,是原生微信小程序开发,支持云开发,该项目包含我们开发的小程序库queryUI库及相关组件,清空相关引用就是一个完整的小程序官方示例代码。更多说明

  • 动态样式支持
  • webpack的各种插件支持
  • 原生微信小程序
  • 动态模板构建
  • template less
  • 支持钩子方法
  • 内置支持 MARKDOWN 富文本
  • 内置支持 HTML 富文本
  • 由aotoo库构建

MD文档系统

这里重点讲一下文档系统,文档系统是一套hub环境的完整演示,包含前端编译,路由,NODE 端镜像路由、插件,MPA/SPA应用,命令行的使用等等,更多说明

  • 全栈系统
  • 栏目首页由 node.js 做服务端渲染 (SSR),浏览器端复用后渲染 (CSR & SPA)
  • 栏目项是多页应用(MPA)切换页面;菜单项是单页应用(SPA)切换路由
  • 自定义同构方法,Fether/requireMarkdown等,Fetcher前端axios构建,node端got构建
  • 仿小程序的前端路由,构建时可以根据路由切割代码,按需加载 js 文件,支持SSR
  • 仿小程序生命周期,让page代码、component代码有更清晰的功能划分
  • template less,尽可能的少写模板,将模板逻辑交由JS实现(aotoo库的核心功能)
  • 支持 markdown
  • 支持 SEO,内部自动解决在浏览器端复用服务端渲染的 html 和数据,无缝过渡
  • 多场景化,测试,生产一应俱全
  • aotoo库构建(react的封装库,react组件jquery化)(说明)

目录结构

aotoo-hub安装完成后的目录结构

hub工作空间
  ├── build
  ├── aotoo.config.js
  ├── index.js
  ├── package.json
  ├── postcss.config.js
  └── src
       # vue 项目演示
       └─ vueSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # react 项目演示
       └─ reactSample
             ├── configs # node环境配置文件,默认包含default.js
             ├── dist      # 静态资源输出目录
             ├── js        # 前端业务js目录(前端)
                 │── venders # 第三方库文件目录+自定义公共库(前端)
                 ...
             └── server    # node端的源码目录
                   │── pages  # node端的业务目录
                   └── plugins # 自定义插件目录
                   
       # 小程序项目演示
       └─ xcxSample 
             ...
             ...
             
       # 文档项目演示
       └─ mdSample 
             ...
             ...

说明

aotoo.config.js

项目配置文件,包含项目版本,项目类型,项目源码目录等各种配置

apps: [
    {
      name: 'reactSample', // 项目名称
      version: '1.0.1',   // 项目版本,每个项目有自己的版本
      startup: true,      // 是否默认启动
      server: false,      // 是否提供node端服务,默认为`dev-server`提供服务
      type: 'web',        // 项目类型, 'mp'为小程序
      src: path.join(ROOT, 'src/reactSample'), // 源码目录
      micro: false,       // 是否微服务模式(开发中,未完善)
      options: {          // 项目扩展参数
        scenes: 'default',// 默认项目环境,将生成环境配置文件 
        cloud: false,     // 小程序项目是否启动云端
      },
      port: 8500          // 项目端口
    },
    
    {
      name: 'vueSample',
      ...
    },
    
    {
      name: 'xcxSample',
      ...
    }
  ],
}

configs目录

该目录存放环境配置,编译器会根据命令行所给出的环境参数生成相关的配置,比如测试环境(test=>test.js)

aotoo dev --config test

dist目录

该目录存放前端编译文件,包含版本目录,开发目录,生产目录

├─ dist          # 输出目录(前端)
    │─ 1.0.1     # 版本目录,依据配置中的version字段
    └─ 1.0.8
        └─ dev # 开发目录
            │── js/index.js
            │── html/index.html
             ...
            └── mapfile.json # 静态资源镜像文件

        └─ pro # 生产目录
            │── js/index_[hash].js # 生产文件会带上hash
             ...
            └── mapfile.json # 静态资源镜像文件

js目录

该目录存放前端JS源码,公共JS源码,业务JS源码

├─ js         
    │─ vendors # 公共JS
    └─ index # 前端业务目录
         │─ index.js  # 业务 => dist/.../index.js
         │─ index.scss # 样式 => dist/.../index.css
         └─ index.html # 模板 => dist/.../index.html
         
    └─ shop # 前端业务目录
         │─ index.js  # 业务 => dist/.../shop.js
         │─ index.scss # 样式 => dist/.../shop.css
         └─ index.html # 模板 => dist/.../shop.html

编译思路
编译器遍历业务JS文件并生成其配套资源(html/css/node)

dll打包(vendors目录)
这里dll打包指代打包vendors.jscommon.js,需要注意的是,vendors是一套独立的webpack打包进程,与业务JS打包不在一个进程,将生成dist/.../vendors.js文件,common.js来自业务JS的分离代码(webpack的split)。其中vendors.js文件不支持热更新

业务打包
业务打包以JS文件作为webpack的entry,通过loader模块同时生成其相关静态资源,如上例的shop目录

js/vendors目录

该目录存放通用代码或者框架库,比如在vendors/index.js中引入react或者vue,需要注意,该目录的内容会被应用到node端(如果启用了server服务),所以一定要注意两端的兼容性写法

server目录

当你在配置中设置server参数为true时,编译器将会自动生成server目录,并将后端服务由webpack-dev-server转向由node端提供的服务

镜像路由server/pages
当你在配置中设置server参数为true时,编译器会遍历entry(业务JS),并镜像生成其node端的controler文件,如上例shop

server
  └─ pages
      └─ shop
          └─ index.js
controler文件默认提供渲染服务、GET、POST接口服务

node端插件server/plugins
该目录需要手动生成,插件支持自定义路由,支持自定义功能模块

命令行工具

aotoo-cli是aotoo-hub的命令行开发工具库,安装、编译都需要通过此工具来操作更多说明

系统环境

  • mac osx
  • linux
  • 不支持windows,但 win10 的 linux 子系统可以
  • node >= 12

INSTALL

npm install -g aotoo-cli
aotoo -V # 检查是否安装成功

USAGE

安装一个新的hub空间

init <dir>

# 新建xxx项目
$ aotoo init xxx

新建web类项目

create <dir>

自动创建pc/h5/公众号等web项目并启动项目

# 新建项目
$ cd xxx
$ aotoo create projectName
将会在xxx/src目录下,创建项目目录projectName,按照提示输Y,回车继续
完成安装后,修改aotoo.config.js,补充项目描述

新建小程序项目

create <dir>

适用于创建小程序项目

# 新建项目
$ cd xxx
$ aotoo create minip_name
将会在xxx/src目录下,创建项目目录minip_name,提示请输入n
完成安装后,打开aotoo.config.js,补充项目描述,重新启动项目

启动自带项目

安装完成后,hub的src目录下包含4个演示项目,通过以下命令可以分别启动

启动文档项目

# 文档项目属于默认项目,可以直接启动
$ aotoo dev

启动VUE项目

$ aotoo dev vueSample

启动REACT项目

$ aotoo dev reactSample

启动小程序项目

编译完成后需要使用小程序开发工具打开终端黄色字体指示的目录

$ aotoo dev xcxSample

工作模式

dev [name]

开发模式编译,并启动服务,前端支持热更新

# 启动aotoo.config中apps中的启动项目
$ aotoo dev

# 启动指定项目
$ aotoo dev project_name

dev-clean [name]

开发模式编译,清除common/vendors等dll文件,重新生成,并启动服务

$ aotoo dev-clean

# 启动指定项目,并清除该项目common的缓存
$ aotoo dev-clean project_name

dev-build [name]

开发模式编译,清除common/vendors等dll文件,重新生成,不启动服务

$ aotoo dev-build

# 编译指定项目
$ aotoo dev-build project_name

生产模式

build [name]

生产模式,纯编译输出,不启动服务

$ aotoo build

# 编译指定项目
$ aotoo build project_name

build-clean [name]

生产模式,清除common/vendors等dll文件,并重新生成,不启动服务

$ aotoo build-clean

# 编译指定项目
$ aotoo build-clean project_name

部署

部署之前,需要先以生产模式编译

aotoo build-clean project_name

start [name]

生产模式,启动node服务

$ aotoo start

# 编译指定项目
$ aotoo start project_name

node启动

# 编译项目,生产模式
$ node index.js

# 启动测试环境
$ node index.js --config test

# 进入源码server目录启动  
$ cd src/project_name/server
$ node index.js

pm2启动

# 编译项目,生产模式
$ aotoo build-clean project_name

# 启动测试环境
$ pm2 start index.js -- --config test

EXTEND PARAM 扩展参数

--config <name>

指定环境配置文件,配合node端一起使用

# 开发模式,启动测试环境
aotoo dev --config test

# 生产模式,启动测试环境  
aotoo build --config test

--name <name>

启动指定项目,该参数可多项目同时启动

# 同时启动 xxx 及 yyy的项目
$ aotoo dev --name vueDemo --name reactDemo

GITHUB
更多说明

查看原文

赞 1 收藏 1 评论 0

天天修改 赞了文章 · 2020-12-20

用JS来解释JS:简易实现JS解释器

前言

说到编译原理,对于一个小前端来说,既远又近,远是因为如果单单进行业务开发,你可能基本不需要接触到它;但是当你想往深处进阶时,你就会发现,现在前端的世界里,到处都是编译原理的应用,而且这些应用其实离你非常近,比如日常用到的webpackrollupbabel甚至postcss,最近的多端打包框架Tarompvue也离不开编译原理的应用;

回想起大学时候的编译原理课程以及书籍,十分晦涩难懂,工作后,如果工作内容与编译原理脱节的话,难免会越来越生疏;但是如果想要技术更上一层楼,作为计算机基础学科之一的编译原理,是必须要掌握的;

本人在使用babel对代码进行优化的过程中,慢慢地对编译原理有进一步地认识,在前人的启发下(JSJS),尝试实现一个JS解释器,用JS来解释JS;

先声明:编译原理远远不止本文讲的内容,其还有很多内容是本文没有提及的。

仓库地址:https://github.com/jackie-gan...

准备工作

首先何为JS解释器?简单来说,就是使用JS来运行JS;

既然需要用JS来运行JS,那么就需要做到读懂JS,以及主动执行JS;

例如,如何执行console.log('123');的语句呢:

  • 首先需要解释JS语句中的各个单词consolelog'123'
  • 然后找到它们是属于什么语法,例如console.log('123');其实属于一个CallExpressioncallee是一个MemberExpression
  • 最后找到MemberExpression中的console对象,再找到它的log函数,最后执行函数,输出123

所以为了达到读懂和执行JS,就需要用到以下工具:

  • acorn,代码解析的工具,能把JS代码转换成对应的AST语法树;
  • astexplorer,直观地查看AST语法树;

实现思路

  • 第一步,由acorn将代码转换成AST语法树;
  • 第二步,自定义遍历器以及节点处理函数;
  • 第三步,在处理函数中,执行目标代码,并递归执行;
  • 第四步,解释器入口函数先处理第一个AST节点;

遍历器实现

acorn转换的AST语法树符合ESTree规范,例如通过astexplorer查看console.log('123');语句转换后的AST语法树,它是这样的:

可以看到,语法树上有不同的节点类型,因此,需要继续定义好每个节点的处理函数:

const es5 = {
  Program() {},
  ExpressionStatement() {},
  BlockStatement() {},
  ThisExpression() {},
  ObjectExpression() {},
  BinaryExpression() {},
  Literal() {},
  Identifier() {},
  VariableDeclaration() {},
  ...
};

接下来,就需要实现一个遍历器,用于依次递归遍历语法树的节点,以达到最终把语法树遍历完:

const vistorsMap = {
  ...es5
};

export function evaluate(astPath: AstPath<ESTree.Node>) {
  const visitor = vistorsMap[astPath.node.type];

  return visitor(astPath);
}

节点处理函数

AST语法树节点的处理就好像对DOM树的节点处理一样,遍历到节点后,对节点按照规范处理即可;

本文目前只实现了es5规范的代码解释,所以处理的节点主要是以es5的节点为主,下面就举例部分节点的处理方法:

Program节点

作为整个AST语法树的根节点,只需要依次遍历节点的body属性,body中节点顺序就是JS语句的执行顺序;

Program: (astPath: AstPath<ESTree.Program>) => {
  const { node, scope, evaluate } = astPath; 
  node.body.forEach((bodyNode) => {
    evaluate({ node: bodyNode, scope, evaluate });
  });
},

BinaryExpression节点

处理二元运算表达式节点,需要首先将leftright两个表达式,然后根据operator执行相应的计算,最后返回处理结果。

BinaryExpression: (astPath: AstPath<ESTree.BinaryExpression>) => {
  const { node, scope, evaluate } = astPath;
  const leftVal = evaluate({ node: node.left, scope, evaluate });
  const rightVal = evaluate({ node: node.right, scope, evaluate });
  const operator = node.operator;

  const calculateFunc = {
    '+': (l, r) => l + r,
    '-': (l, r) => l - r,
    '*': (l, r) => l * r,
    '/': (l, r) => l / r,
    '%': (l, r) => l % r,
    '<': (l, r) => l < r,
    '>': (l, r) => l > r,
    '<=': (l, r) => l <= r,
    '>=': (l, r) => l >= r,
    '==': (l, r) => l == r,
    '===': (l, r) => l === r,
    '!=': (l, r) => l != r,
    '!==': (l, r) => l !== r
  };

  if (calculateFunc[operator]) return calculateFunc[operator](leftVal, rightVal);
  else throw `${TAG} unknow operator: ${operator}`;
}

WhileStatement节点

While循环的节点包含testbody属性;test属性是while循环的条件,所以需要继续递归遍历,而body表示while循环内的逻辑,也需要继续递归遍历;

WhileStatement: (astPath: AstPath<ESTree.WhileStatement>) => {
  const { node, scope, evaluate } = astPath;
  const { test, body } = node;

  while (evaluate({ node: test, scope, evaluate })) {
    const result = evaluate({ node: body, scope, evaluate });

    if (Signal.isBreak(result)) break;
    if (Signal.isContinue(result)) continue;
    if (Signal.isReturn(result)) return result.result;
  }
}

这里需要额外注意的是,在While循环中,可能会遇到breakcontinue或者return的关键字终止循环逻辑;所以需要对这些关键字进行额外处理;

关键字处理

breakcontinuereturn也有对应的节点类型BreakStatementContinueStatementReturnStatement;我们需要另外定义一个关键字基类Signal,它的实例作为这些关键字节点类型函数的返回值,以便它们的上一级能处理;

BreakStatement: () => {
  // 返回结果到上一级
  return new Signal('break');
}

ContinueStatement: () => {
  // 返回结果到上一级
  return new Signal('continue');
}

ReturnStatement: (astPath: AstPath<ESTree.ReturnStatement>) => {
  const { node, scope, evaluate } = astPath;
  // 返回结果到上一级
  return new Signal('return', node.argument ? evaluate({ node: node.argument, scope, evaluate }) : undefined);
}

Signal基类如下:

type SignalType = 'break' | 'continue' | 'return';

export class Signal {
  public type: SignalType
  public value?: any

  constructor(type: SignalType, value?: any) {
    this.type = type;
    this.value = value;
  }

  private static check(v, t): boolean {
    return v instanceof Signal && v.type === t;
  }

  public static isContinue(v): boolean {
    return this.check(v, 'continue');
  }

  public static isBreak(v): boolean {
    return this.check(v, 'break');
  }

  public static isReturn(v): boolean {
    return this.check(v, 'return');
  }
}

更多节点处理

因为AST节点类型太多了,会本文篇幅太长,需要看其他节点的处理的话,可以直接到Git仓库查看;

在处理到VariableDeclaration节点的时候,也就是变量声明时,就发现一个问题了:定义的变量应该保存在哪里呢?

这个时候就需要引入作用域的概念了;

作用域

我们都知道,JS全局作用域函数作用域块级作用域的概念;

全局上下文中定义的变量,应该保存在全局上下文中,而在函数上下文中定义的变量,应该保存在函数作用域中;

export class Scope {
  private parent: Scope | null;
  private content: { [key: string]: Var };
  public invasive: boolean;

  constructor(public readonly type: ScopeType, parent?: Scope) {
    this.parent = parent || null;
    this.content = {};  // 当前作用域的变量
  }

  /**
   * 存储到上一级的作用域中 
   */
  public var(rawName: string, value: any): boolean {
    let scope: Scope = this;

    // function定义在函数作用域内
    while (scope.parent !== null && scope.type !== 'function') {
      scope = scope.parent;
    }

    scope.content[rawName] = new Var('var', value);
    return true;
  }

  /**
   * 只在当前作用域定义
   */
  public const(rawName: string, value: any): boolean {
    if (!this.content.hasOwnProperty(rawName)) {
      this.content[rawName] = new Var('const', value);
      return true;
    } else {
      // 已经定义了
      return false;
    }
  }

  /**
   * 
   */
  public let(rawName: string, value: any): boolean {
    if (!this.content.hasOwnProperty(rawName)) {
      this.content[rawName] = new Var('let', value);
      return true;
    } else {
      // 已经定义了
      return false;
    }
  }

  /**
   * 从作用域上查找变量
   */
  public search(rawName: string): Var | null {
    // 1.先从当前作用域查找
    if (this.content.hasOwnProperty(rawName)) {
      return this.content[rawName];
    // 2.如果没有,则继续往上级查找
    } else if (this.parent) {
      return this.parent.search(rawName);
    } else {
      return null;
    }
  }

  public declare(kind: KindType, rawName: string, value: any): boolean {
    return ({
      'var': () => this.var(rawName, value),
      'const': () => this.const(rawName, value),
      'let': () => this.let(rawName, value)
    })[kind]();
  }
}

当遇到BlockStatement时,就需要形成一个Scope实例,因为类似constlet定义的变量,会形成块级作用域,它们的值会保存在当前块级作用域中;

而此时,BlockStatement中的var变量依然需要定义在上一级的作用域中,直到遇到函数作用域,因此,在定义var时,会有如下的处理:

public var(rawName: string, value: any): boolean {
  let scope: Scope = this;

  // 把变量定义到父级作用域
  while (scope.parent !== null && scope.type !== 'function') {
    scope = scope.parent;
  }

  scope.content[rawName] = new Var('var', value);
  return true;
}

入口函数

到此,已经具备了读懂JS和执行JS的能力了,下面就定义一个入口函数,并且把执行结果进行输出:

export function execute(code: string, externalApis: any = {}) {
  // 全局作用域
  const scope = new Scope('root');
  scope.const('this', null);

  for (const name of Object.getOwnPropertyNames(defaultApis)) {
    scope.const(name, defaultApis[name]);
  }

  for (const name of Object.getOwnPropertyNames(externalApis)) {
    scope.const(name, externalApis[name]);
  }

  // 模块导出
  const $exports = {};
  const $module = { exports: $exports };
  scope.const('module', $module);
  scope.var('exports', $exports);

  const rootNode = acorn.parse(code, {
    sourceType: 'script'
  });

  const astPath: AstPath<ESTree.Node> = {
    node: rootNode,
    evaluate,
    scope
  }

  evaluate(astPath);

  // 导出结果
  const moduleExport = scope.search('module');

  return moduleExport ? moduleExport.getVal().exports : null;
}

入口函数execute接收两个参数:

  • code为转换为字符串后的代码;
  • externalApis为一些“内置”对象;

入口函数的结果输出,通过自定义的module.exports对象实现;

目前能做什么

目前这个解释器只是一个雏形,它目前只能做一些简单的JS解释:

  • 例如跑通所写的测试用例;
  • 在类似小程序的环境中做一些简单地代码执行;

如果把打包完后的js,放到小程序中运行,它的运行效果如下:

  • 样例代码:
const interpreter = require('./interpreter');

// 实例一:
interpreter.execute(`
  wx.showModal({
    title: '实例一',
    success: function() {
      wx.showToast({
        title: '点击按钮啦'
      });
    }
  });
`, { wx: wx });

// 实例二:
interpreter.execute(`
  setTimeout(function() {
    wx.showToast({
      title: '倒计时完成'
    });
  }, 1000);
`, { wx: wx });
  • 效果如下:

example-1
example-2

结语

在实现JS解释的过程中,也是一个对JS语言进行更深一步理解的过程;后续将会继续优化这个解释器,比方说:

  • 处理变量提升;
  • 提供更多es6+的处理;
  • 等等

本人先抛砖引玉,欢迎各位大佬交流!

查看原文

赞 2 收藏 1 评论 0

天天修改 赞了文章 · 2020-10-16

聊聊 React 两个状态管理库 Redux & Recoil

State Management in React Apps | WalkingTree Technologies

背景

React 是一个十分优秀的UI库, 最初的时候, React 只专注于UI层, 对全局状态管理并没有很好的解决方案, 也因此催生出类似Flux, Redux 等优秀的状态管理工具。

随着时间的演变, 又催化了一批新的状态管理工具。

简单整理了一些目前主流的状态管理工具:

  1. Redux
  2. React Context & useReducer
  3. Mobx
  4. Recoil
  5. react-sweet-state
  6. hox

这几个都是我接触过的,Npm 上的现状和趋势对比

image.png

image.png

毫无疑问,ReactRedux 的组合是目前的主流。

今天5月份, 一个名叫 Recoil.js 的新成员进入了我的视野,带来了一些有趣的模型和概念,今天我们就把它和 Redux 做一个简单的对比, 希望能对大家有所启发。

正文

先看 Redux:

Redux

React-Redux 架构图:

image.png

这个模型还是比较简单的, 大家也都很熟悉。

先用一个简单的例子,回顾一下整个模型:

actions.js

export const UPDATE_LIST_NAME = 'UPDATE_NAME';

reducers.js

export const reducer = (state = initialState, action) => {
    const { listName, tasks } = state;
    switch (action.type) {
        case 'UPDATE_NAME': {
            // ...
        }        
    default: {
            return state;
        }
    }
};

store.js

import reducers from '../reducers';
import { createStore } from 'redux';
const store = createStore(reducers);
export const TasksProvider = ({ children }) => (
    <Provider store={store}>
       {children}
    </Provider>
);

App.js

import { TasksProvider } from './store';
import Tasks from './tasks';
const ReduxApp = () => (
    <TasksProvider>
       <Tasks />
   </TasksProvider>
);

Component

// components
import React from 'react';
import { updateListName } from './actions';
import TasksView from './TasksView';

const Tasks = (props) => {
    const { tasks } = props;
    return (
        <TasksView tasks={tasks} />
    );
};

const mapStateToProps = (state) => ({
  tasks: state.tasks
});

const mapDispatchToProps = (dispatch) => ({
    updateTasks: (tasks) => dispatch(updateTasks(tasks))
});

export default connect(mapStateToProps, mapDispatchToProps)(Tasks);

当然也可以不用connect, react-redux 提供了 useDispatch, useSelector 两个hook, 也很方便。

import { useDispatch, useSelector } from 'react-redux';
const Tasks = () => {
    const dispatch = useDispatch();
    const name = useSelector(state => state.name);
    const setName = (name) => dispatch({ type: 'updateName', payload: { name } });
    return (
        <TasksView tasks={tasks} />
    );
};

image.png

整个模型并不复杂,而且redux 还推出了工具集redux toolkit,使用它提供的createSlice方法去简化一些操作, 举个例子:

// Action
export const UPDATE_LIST_NAME = 'UPDATE_LIST_NAME';

// Action creator
export const updateListName = (name) => ({
    type: UPDATE_LIST_NAME,
    payload: { name }
});

// Reducer
const reducer = (state = 'My to-do list', action) => {
    switch (action.type) {
        case UPDATE_LIST_NAME: {
            const { name } = action.payload;
            return name;
        }

        default: {
            return state;
        }
    }
};

export default reducer;

使用 createSlice

// src/redux-toolkit/state/reducers/list-name
import { createSlice } from '@reduxjs/toolkit';

const listNameSlice = createSlice({
    name: 'listName',
    initialState: 'todo-list',
    reducers: {
        updateListName: (state, action) => {
            const { name } = action.payload;
            return name;
        }
    }
});

export const {
    actions: { updateListName },
} = listNameSlice;

export default listNameSlice.reducer;

通过createSlice, 可以减少一些不必要的代码, 提升开发体验。

尽管如此, Redux 还有有一些天然的缺陷

  1. 概念比较多,心智负担大。
  2. 属性要一个一个 pick,计算属性要依赖 reselect。还有魔法字符串等一系列问题,用起来很麻烦容易出错,开发效率低。
  3. 触发更新的效率也比较差。对于connect到store的组件,必须一个一个遍历,组件再去做比较,拦截不必要的更新, 这在注重性能或者在大型应用里, 无疑是灾难。

对于这个情况, React 本身也提供了解决方案, 就是我们熟知的 Context.

Image for post

<MyContext.Provider value={/* some value */}>

<MyContext.Consumer>
  {value => /* render something based on the context value */}
</MyContext.Consumer>

给父节点加 Provider 在子节点加 Consumer,不过每多加一个 item 就要多一层 Provider, 越加越多:

Recoil - Ideal React State Management Library? - DEV

而且,使用Context 问题也不少。

对于使用 useContext 的组件,最突出的就是问题就是 re-render.

不过也有对应的优化方案: React-tracked.

稍微举个例子:

// store.js
import React, { useReducer } from 'react';
import { createContainer } from 'react-tracked';
import { reducers } from './reducers';

const useValue = ({ reducers, initialState }) => useReducer(reducer, initialState);
const { Provider, useTracked, useTrackedState, useUpdate } = createContainer(useValue);

export const TasksProvider = ({ children, initialState }) => (
    <Provider reducer={reducer} initialState={initialState}>
        {children}
    </Provider>
);

export { useTracked, useTrackedState, useUpdate };

对应的,也有 hooks 版本:

const [state, dispatch] = useTracked();
const dispatch = useUpdate();
const state = useTrackedState();

// ...

Recoil

Recoil.js 提供了另外一种思路, 它的模型是这样的:

Image for post

在 React tree 上创建另一个正交的 tree,把每片 item 的 state 抽出来。

每个 component 都有对应单独的一片 state,当数据更新的时候对应的组件也会更新。

Recoil 把 这每一片的数据称为 Atom,Atom 是可订阅可变的 state 单元。

这么说可能有点抽象, 看个简单的例子吧:

// index.js

import React from "react";
import ReactDOM from "react-dom";
import { RecoilRoot } from "recoil";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <App />
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

Recoil Root

Provides the context in which atoms have values. Must be an ancestor of any component that uses any Recoil hooks. Multiple roots may co-exist; atoms will have distinct values within each root. If they are nested, the innermost root will completely mask any outer roots.

可以把 RecoilRoot 看成顶层的 Provider.

Atoms

假设, 现在要实现一个counter:

Image for post

先用 useState 实现:

import React, { useState } from "react";

const App = () => {
  const [count, setCount] = useState(0);
  return (
    <div className="App">
      <button onClick={() => setCount(count + 1)}>Increase</button>
      <button onClick={() => setCount(count - 1)}>Decrease</button>
      <div>Count is {count}</div>
    </div>
  );
};

export default App;

再用 atom 改写一下:

import React from "react";
import { atom, useRecoilState } from "recoil";

const countState = atom({
  key: "counter",
  default: 0,
});

const App = () => {
  const [count, setCount] = useRecoilState(countState);
  
  return (
    <div className="app">
      <button onClick={() => setCount(count + 1)}>Increase</button>
      <button onClick={() => setCount(count - 1)}>Decrease</button>
      <div>Count is {count}</div>
    </div>
  );
};

export default App;

看到这, 你可能对atom 有一个初步的认识了。

那 atom 具体是个什么概念呢?

Atom

简单理解一下,atom 是包含了一份数据的集合,这个集合是可共享,可修改的。

组件可以订阅atom, 可以是一个, 也可以是多个,当 atom 发生改变时,触发再次渲染。

const someState = atom({
    key: 'uniqueString',
    default: [],
});

每个atom 有两个参数:

  • key:用于内部识别atom的字符串。相对于整个应用程序中的其他原子和选择器,该字符串应该是唯一的
  • default:atom的初始值。

atom 是存储状态的最小单位, 一种合理的设计是, atom 尽量小, 保持最大的灵活性。

Recoil 的作者, 在 ReactEurope video 中也介绍了以后一种封装定atom 的方法:

export const itemWithId =
    memoize(id => atom({
        key: `item${id}`,
        default: {...},
    }));

Selectors

官方描述:

“A selector is a pure function that accepts atoms or other selectors as input. When these upstream atoms or selectors are updated, the selector function will be re-evaluated.”

selector 是以 atom 为参数的纯函数, 当atom 改变时, 会触发重新计算。

selector 有如下参数:

  • key:用于内部识别 atom 的字符串。相对于整个应用程序中的其他原子和选择器,该字符串应该是唯一的.
  • get:作为对象传递的函数{ get },其中get是从其他案atom或selector检索值的函数。传递给此函数的所有atom或selector都将隐式添加到selector的依赖项列表中。
  • set?:返回新的可写状态的可选函数。它作为一个对象{ get, set }和一个新值传递。get是从其他atom或selector检索值的函数。set是设置原子值的函数,其中第一个参数是原子名称,第二个参数是新值。

看个具体的例子:

import React from "react";
import { atom, selector, useRecoilState, useRecoilValue } from "recoil";

const countState = atom({
  key: "myCount",
  default: 0,
});

const doubleCountState = selector({
  key: "myDoubleCount",
  get: ({ get }) => get(countState) * 2,
});

const inputState = selector({
  key: "inputCount",
  get: ({ get }) => get(doubleCountState),
  set: ({ set }, newValue) => set(countState, newValue),
});

const App = () => {
  const [count, setCount] = useRecoilState(countState);
  const doubleCount = useRecoilValue(doubleCountState);
  const [input, setInput] = useRecoilState(inputState);
  
  return (
    <div className="App">
      <button onClick={() => setCount(count + 1)}>Increase</button>
      <button onClick={() => setCount(count - 1)}>Decrease</button>
      <input type="number" value={input} onChange={(e) => setInput(Number(e.target.value))} />
      <div>Count is {count}</div>
      <div>Double count is {doubleCount}</div>
    </div>
  );
};

export default App;

比较好理解, useRecoilStateuseRecoilValue 这些基础概念可以参考官方文档

另外, selector 还可以做异步, 比如:

  get: async ({ get }) => {
    const countStateValue = get(countState);
    const response = await new Promise(
      (resolve) => setTimeout(() => resolve(countStateValue * 2)),
      1000
    );
    return response;
  }

不过对于异步的selector, 需要在RecoilRoot加一层Suspense:

ReactDOM.render(
  <React.StrictMode>
    <RecoilRoot>
      <React.Suspense fallback={<div>Loading...</div>}>
          <App />
      </React.Suspense>
    </RecoilRoot>
  </React.StrictMode>,
  document.getElementById("root")
);

Redux vs Recoil

模型对比:

image.png

Recoil 推荐 atom 足够小, 这样每一个叶子组件可以单独去订阅, 数据变化时, 可以达到 O(1)级别的更新.

Recoil 作者 Dave McCabe一个评论中提到:

Well, I know that on one tool we saw a 20x or so speedup compared to using Redux. This is because Redux is O(n) in that it has to ask each connected component whether it needs to re-render, whereas we can be O(1).
useReducer is equivalent to useState in that it works on a particular component and all of its descendants, rather than being orthogonal to the React tree.

Rocil 可以做到 O(1) 的更新是因为,当atom数据变化时,只有订阅了这个 atom 的组件需要re-render。

不过, 在Redux 中,我们也可以用selector 实现同样的效果:

// selector
const taskSelector = (id) => state.tasks[id];

// component code
const task = useSelector(taskSelector(id));

不过这里的一个小问题是,state变化时,taskSelector 也会重新计算, 不过我们可以用createSelector 去优化, 比如:

import { createSelector } from 'reselect';

const shopItemsSelector = state => state.shop.items;

const subtotalSelector = createSelector(
  shopItemsSelector,
  items => items.reduce((acc, item) => acc + item.value, 0)
)

写到这里, 是不是想说,就这? 扯了这么多, Rocoil 能做的, Redux 也能做, 那要你何用?

哈哈, 这个确实有点尴尬。

不过我认为,这是一种模式上的改变,recoil 鼓励把每一个状态做的足够小, 任意组合,最小范围的更新。

而redux, 我们的习惯是, 把容器组件连接到store上, 至于子组件,哪怕往下传一层,也没什么所谓。

我想,Recoil 这么设计,可能是十分注重性能问题,优化超大应用的性能表现。

目前,recoil 还处于玩具阶段, 还有大量的 issues 需要处理, 不过值得继续关注。

最后

感兴趣的朋友可以看看, 做个todo-list体验一下。

希望这篇文章能帮到你。

才疏学浅,文中若有错误, 欢迎指正。


如果你觉得这篇内容对你挺有启发,可以:

  1. 点个「在看」,让更多的人也能看到这篇内容。
  2. 关注公众号「前端e进阶」,掌握前端面试重难点,公众号后台回复「加群」和小伙伴们畅聊技术。

图片

参考资料

  1. http://react.html.cn/docs/context.html#reactcreatecontext
  2. https://recoiljs.org/docs/basic-tutorial/atoms
  3. https://www.emgoto.com/react-state-management/
  4. https://medium.com/better-programming/recoil-a-new-state-management-library-moving-beyond-redux-and-the-context-api-63794c11b3a5
查看原文

赞 18 收藏 10 评论 0

天天修改 赞了文章 · 2020-10-06

前端实用小工具(URL参数截取、JSON判断、数据类型检测、版本号对比等)

背景

在日常开发中,我们经常会用一些工具类方法来实现业务逻辑 下面列举几种最常用的

URL截取参数

//直接调用输入想要截取的参数名称几个
export function getParamFromUrl(key) {
    if (key === undefined) return null;
    let search = location.search.substr(1);
    let mReg = new RegExp('(^|&)' + key + '=([^&]*)(&|$)');
    let mValue = search.match(mReg);
    if (mValue != null) return unescape(mValue[2]);
    return null;
}
//示例
let city = getParamFromUrl('city');

JSON是否为空判断

//输入想要检测的json数据 如果为空返回返回false
export function isNullObject(model) {
  if (typeof model === "object") {
    let hasProp = false;
    for (const prop in model) {
        hasProp = true;
        break;
    }
    if (hasProp) {
        return false;
    }
    return true;
  } else {
      throw "model is not object";
  }
}

image-20200929171431032

数据类型检测

//检测变量的数据类型
export function getParamType(item) {
    if (item === null) return null;
    if (item === undefined) return undefined;
    return Object.prototype.toString.call(item).slice(8, -1);
}
//返回String Function Boolean Object Number

image-20200929171150164

获取cookie

//获取document下cookie的具体某个参数值
export function getCookie(key) {
    if (key === undefined) {
        return undefined;
    }
    let cookies = document.cookie;
    let mReg = new RegExp('(^|;)\\s*' + key + '=([^;]*)(;|$)');
    let mValue = cookies.match(mReg);
    let ret = undefined;
    if (mValue != null) {
        ret = unescape(mValue[2]);
    }
    if (ret !== undefined) {
        ret = ret.replace(/^\"|\'/i, '').replace(/\"|\'$/i, '');
    }
    return ret;
}

image-20200930103240035

版本号对比

一般在做APP端开发的时候需要用到一些版本控制,那么就需要针对版本号来进行对比,高版本或者低版本做一些特殊的逻辑处理,下面就是提供版本对比的方法

//传入要对比的版本号,一般前面一个传入当前的版本号,后面一个写上要对比的版本号
export function versionCompare(higher, lower) {
    let sep = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : '.';

    let higherAry = higher.split(sep),
        lowerAry = lower.split(sep);
    let l = Math.max(higherAry.length, lowerAry.length);
    for (let i = 0; i < l; i++) {
        let high = parseInt(higherAry[i] || 0);
        let low = parseInt(lowerAry[i] || 0);
        if (high > low) {
            return 1;
        }
        if (high < low) {
            return -1;
        }
    }
    return 0;
}
//返回值  higher > lower: 1;higher = lower: 0;higher < lower:-1

image-20200930103754427

数组去重

export function arrayUniq(array){
    let temp = []; 
    for(var i = 0; i < array.length; i++){
        if(temp.indexOf(array[i]) == -1){
            temp.push(array[i]);
        }
    }
    return temp;
}

image-20200930104914363

iPhone X系列机型判断

export function isIphoneX() {
    // iPhone X、iPhone XS
    var isIPhoneX =
        /iphone/gi.test(window.navigator.userAgent) &&
        window.devicePixelRatio &&
        window.devicePixelRatio === 3 &&
        window.screen.width === 375 &&
        window.screen.height === 812;
    // iPhone XS Max
    var isIPhoneXSMax =
        /iphone/gi.test(window.navigator.userAgent) &&
        window.devicePixelRatio &&
        window.devicePixelRatio === 3 &&
        window.screen.width === 414 &&
        window.screen.height === 896;
    // iPhone XR
    var isIPhoneXR =
        /iphone/gi.test(window.navigator.userAgent) &&
        window.devicePixelRatio &&
        window.devicePixelRatio === 2 &&
        window.screen.width === 414 &&
        window.screen.height === 896;
    if (isIPhoneX || isIPhoneXSMax || isIPhoneXR) {
        return true;
    }
    return false;
}
查看原文

赞 29 收藏 21 评论 1

认证与成就

  • 获得 9 次点赞
  • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • aotoo-xquery

    小程序开发工具库,UI库

  • aotoo-hub

    前端全栈构建工具

  • FKP-REST

    基于react,koa,webpack等等的全站JS框架

  • FKP-RN

    FKP-RN是一套兼容android和ios的react-native架构

  • FKP-RN-ROUTER

    FKP-RN-ROUTER是一个RN用的router库,通过npm install 安装

注册于 2015-06-03
个人主页被 425 人浏览