飘香豆腐

飘香豆腐 查看完整档案

广州编辑  |  填写毕业院校  |  填写所在公司/组织 jwchan.cn 编辑
编辑

前端

个人动态

飘香豆腐 收藏了文章 · 2020-11-25

带你五步学会Vue SSR

前言

SSR大家肯定都不陌生,通过服务端渲染,可以优化SEO抓取,提升首页加载速度等,我在学习SSR的时候,看过很多文章,有些对我有很大的启发作用,有些就只是照搬官网文档。通过几天的学习,我对SSR有了一些了解,也从头开始完整的配置出了SSR的开发环境,所以想通过这篇文章,总结一些经验,同时希望能够对学习SSR的朋友起到一点帮助。

我会通过五个步骤,一步步带你完成SSR的配置:

  1. 纯浏览器渲染
  2. 服务端渲染,不包含Ajax初始化数据
  3. 服务端渲染,包含Ajax初始化数据
  4. 服务端渲染,使用serverBundleclientManifest进行优化
  5. 一个完整的基于Vue + VueRouter + Vuex的SSR工程

如果你现在对于我上面说的还不太了解,没有关系,跟着我一步步向下走,最终你也可以独立配置一个SSR开发项目,所有源码我会放到github上,大家可以作为参考

正文

1. 纯浏览器渲染

这个配置相信大家都会,就是基于weback + vue的一个常规开发配置,这里我会放一些关键代码,完整代码可以去github查看。

目录结构
- node_modules
- components  
    - Bar.vue
    - Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
app.js
import Vue from 'vue';
import App from './App.vue';

let app = new Vue({
  el: '#app',
  render: h => h(App)
});
App.vue
<template>
  <div>
    <Foo></Foo>
    <Bar></Bar>
  </div>
</template>

<script>
import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';

export default {
  components: {
    Foo, Bar
  }
}
</script>
index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>纯浏览器渲染</title>
</head>
<body>
  <div id="app"></div>
</body>
</html>
components/Foo.vue
<template>
  <div class="foo">
    <h1>Foo Component</h1>
  </div>
</template>

<style>
.foo {
  background: yellowgreen;
}
</style>
components/Bar.vue
<template>
  <div class="bar">
    <h1>Bar Component</h1>
  </div>
</template>

<style>
.bar {
  background: bisque;
}
</style>
webpack.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');

module.exports = {
  mode: 'development',

  entry: './app.js',

  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
        // 如果需要单独抽出CSS文件,用下面这个配置
        // use: ExtractTextPlugin.extract({
        //   fallback: 'vue-style-loader',
        //   use: [
        //     'css-loader',
        //     'postcss-loader'
        //   ]
        // })
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      },
      {
        test: /\.vue$/,
        use: 'vue-loader'
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin(),
    new HtmlWebpackPlugin({
      template: './index.html'
    }),
    // 如果需要单独抽出CSS文件,用下面这个配置
    // new ExtractTextPlugin("styles.css")
  ]
};
postcss.config.js
module.exports = {
  plugins: [
    require('autoprefixer')
  ]
};
.babelrc
{
  "presets": [
    "@babel/preset-env"
  ],
  "plugins": [
    // 让其支持动态路由的写法 const Foo = () => import('../components/Foo.vue')
    "dynamic-import-webpack"    
  ]
}
package.json
{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build": "webpack"
  },
  "dependencies": {
    "vue": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "babel-plugin-dynamic-import-webpack": "^1.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9"
  }
}
命令
启动开发环境
yarn start
构建生产环境
yarn run build

最终效果截图:

纯浏览器渲染

完整代码查看github

2. 服务端渲染,不包含Ajax初始化数据

服务端渲染SSR,类似于同构,最终要让一份代码既可以在服务端运行,也可以在客户端运行。如果说在SSR的过程中出现问题,还可以回滚到纯浏览器渲染,保证用户正常看到页面。

那么,顺着这个思路,肯定就会有两个webpack的入口文件,一个用于浏览器端渲染weboack.client.config.js,一个用于服务端渲染webpack.server.config.js,将它们的公有部分抽出来作为webpack.base.cofig.js,后续通过webpack-merge进行合并。同时,也要有一个server来提供http服务,我这里用的是koa

我们来看一下新的目录结构:

- node_modules
- config    // 新增
    - webpack.base.config.js
    - webpack.client.config.js
    - webpack.server.config.js
- src
    - components  
        - Bar.vue
        - Foo.vue
    - App.vue
    - app.js
    - entry-client.js   // 新增
    - entry-server.js   // 新增
    - index.html
    - index.ssr.html    // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

在纯客户端应用程序(client-only app)中,每个用户会在他们各自的浏览器中使用新的应用程序实例。对于服务器端渲染,我们也希望如此:每个请求应该都是全新的、独立的应用程序实例,以便不会有交叉请求造成的状态污染(cross-request state pollution)。

所以,我们要对app.js做修改,将其包装为一个工厂函数,每次调用都会生成一个全新的根组件。

app.js

import Vue from 'vue';
import App from './App.vue';

export function createApp() {
  const app = new Vue({
    render: h => h(App)
  });

  return { app };
}

在浏览器端,我们直接新建一个根组件,然后将其挂载就可以了。

entry-client.js

import { createApp } from './app.js';

const { app } = createApp();

app.$mount('#app');

在服务器端,我们就要返回一个函数,该函数的作用是接收一个context参数,同时每次都返回一个新的根组件。这个context在这里我们还不会用到,后续的步骤会用到它。

entry-server.js

import { createApp } from './app.js';

export default context => {
  const { app } = createApp();

  return app;
}

然后再来看一下index.ssr.html

index.ssr.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>服务端渲染</title>
</head>
<body>
  <!--vue-ssr-outlet-->

  <script type="text/javascript" data-original="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>

<!--vue-ssr-outlet-->的作用是作为一个占位符,后续通过vue-server-renderer插件,将服务器解析出的组件html字符串插入到这里。

<script type="text/javascript" data-original="<%= htmlWebpackPlugin.options.files.js %>"></script>是为了将webpack通过webpack.client.config.js打包出的文件放到这里(这里是为了简单演示,后续会有别的办法来做这个事情)。

因为服务端吐出来的就是一个html字符串,后续的Vue相关的响应式、事件响应等等,都需要浏览器端来接管,所以就需要将为浏览器端渲染打包的文件在这里引入。

用官方的词来说,叫客户端激活(client-side hydration)

所谓客户端激活,指的是 Vue 在浏览器端接管由服务端发送的静态 HTML,使其变为由 Vue 管理的动态 DOM 的过程。

在 entry-client.js 中,我们用下面这行挂载(mount)应用程序:

// 这里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')

由于服务器已经渲染好了 HTML,我们显然无需将其丢弃再重新创建所有的 DOM 元素。相反,我们需要"激活"这些静态的 HTML,然后使他们成为动态的(能够响应后续的数据变化)。

如果你检查服务器渲染的输出结果,你会注意到应用程序的根元素上添加了一个特殊的属性:

<div id="app" data-server-rendered="true">

Vue在浏览器端就依靠这个属性将服务器吐出来的html进行激活,我们一会自己构建一下就可以看到了。

接下来我们看一下webpack相关的配置:

webpack.base.config.js

const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
  mode: 'development',

  resolve: {
    extensions: ['.js', '.vue']
  },

  output: {
    path: path.resolve(__dirname, '../dist'),
    filename: '[name].bundle.js'
  },

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: 'vue-loader'
      },
      {
        test: /\.js$/,
        use: 'babel-loader'
      },
      {
        test: /\.css$/,
        use: ['vue-style-loader', 'css-loader', 'postcss-loader']
      },
      {
        test: /\.(jpg|jpeg|png|gif|svg)$/,
        use: {
          loader: 'url-loader',
          options: {
            limit: 10000    // 10Kb
          }
        }
      }
    ]
  },

  plugins: [
    new VueLoaderPlugin()
  ]
};

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

注意,这里的入口文件变成了entry-client.js,将其打包出的client.bundle.js插入到index.html中。

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

这里有几个点需要注意一下:

  1. 入口文件是 entry-server.js
  2. 因为是打包服务器端依赖的代码,所以target要设为node,同时,outputlibraryTarget要设为commonjs2

这里关于HtmlWebpackPlugin配置的意思是,不要在index.ssr.html中引入打包出的server.bundle.js,要引为浏览器打包的client.bundle.js,原因前面说过了,是为了让Vue可以将服务器吐出来的html进行激活,从而接管后续响应。

那么打包出的server.bundle.js在哪用呢?接着往下看就知道啦~~

package.json

{
  "name": "01",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "yarn run dev",
    "dev": "webpack-dev-server",
    "build:client": "webpack --config config/webpack.client.config.js",
    "build:server": "webpack --config config/webpack.server.config.js"
  },
  "dependencies": {
    "koa": "^2.5.3",
    "koa-router": "^7.4.0",
    "koa-static": "^5.0.0",
    "vue": "^2.5.17",
    "vue-server-renderer": "^2.5.17"
  },
  "devDependencies": {
    "@babel/core": "^7.1.2",
    "@babel/preset-env": "^7.1.0",
    "autoprefixer": "^9.1.5",
    "babel-loader": "^8.0.4",
    "css-loader": "^1.0.0",
    "extract-text-webpack-plugin": "^4.0.0-beta.0",
    "file-loader": "^2.0.0",
    "html-webpack-plugin": "^3.2.0",
    "postcss": "^7.0.5",
    "postcss-loader": "^3.0.0",
    "style-loader": "^0.23.0",
    "url-loader": "^1.1.1",
    "vue-loader": "^15.4.2",
    "vue-style-loader": "^4.1.2",
    "vue-template-compiler": "^2.5.17",
    "webpack": "^4.20.2",
    "webpack-cli": "^3.1.2",
    "webpack-dev-server": "^3.1.9",
    "webpack-merge": "^4.1.4"
  }
}

接下来我们看server端关于http服务的代码:

server/server.js

const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

// 后端Server
backendRouter.get('/index', (ctx, next) => {
  // 这里用 renderToString 的 promise 返回的 html 有问题,没有样式
  renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
  });
});

backendApp.use(serve(path.resolve(__dirname, '../dist')));

backendApp
  .use(backendRouter.routes())
  .use(backendRouter.allowedMethods());

backendApp.listen(3000, () => {
  console.log('服务器端渲染地址: http://localhost:3000');
});


// 前端Server
frontendRouter.get('/index', (ctx, next) => {
  let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
  ctx.type = 'html';
  ctx.status = 200;
  ctx.body = html;
});

frontendApp.use(serve(path.resolve(__dirname, '../dist')));

frontendApp
  .use(frontendRouter.routes())
  .use(frontendRouter.allowedMethods());

frontendApp.listen(3001, () => {
  console.log('浏览器端渲染地址: http://localhost:3001');
});

这里对两个端口进行监听,3000端口是服务端渲染,3001端口是直接输出index.html,然后会在浏览器端走Vue的那一套,主要是为了和服务端渲染做对比使用。

这里的关键代码是如何在服务端去输出html`字符串。

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

可以看到,server.bundle.js在这里被使用啦,因为它的入口是一个函数,接收context作为参数(非必传),输出一个根组件app

这里我们用到了vue-server-renderer插件,它有两个方法可以做渲染,一个是createRenderer,另一个是createBundleRenderer

const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 选项 */ })
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 选项 */ })

createRenderer无法接收为服务端打包出的server.bundle.js文件,所以这里只能用createBundleRenderer

serverBundle 参数可以是以下之一:

  • 绝对路径,指向一个已经构建好的 bundle 文件(.js.json)。必须以 / 开头才会被识别为文件路径。
  • webpack + vue-server-renderer/server-plugin 生成的 bundle 对象。
  • JavaScript 代码字符串(不推荐)。

这里我们引入的是.js文件,后续会介绍如何使用.json文件以及有什么好处。

renderer.renderToString((err, html) => {
    if (err) {
      console.error(err);
      ctx.status = 500;
      ctx.body = '服务器内部错误';
    } else {
      console.log(html);
      ctx.status = 200;
      ctx.body = html;
    }
});

使用createRenderercreateBundleRenderer返回的renderer函数包含两个方法renderToStringrenderToStream,我们这里用的是renderToString成功后直接返回一个完整的字符串,renderToStream返回的是一个Node流。

renderToString支持Promise,但是我在使用Prmoise形式的时候样式会渲染不出来,暂时还不知道原因,如果大家知道的话可以给我留言啊。

配置基本就完成了,来看一下如何运行。

yarn run build:client       // 打包浏览器端需要bundle
yarn run build:server       // 打包SSR需要bundle

yarn start      // 其实就是 node server/server.js,提供http服务

最终效果展示:

访问http://localhost:3000/index

SSR没有ajax数据

我们看到了前面提过的data-server-rendered="true"属性,同时会加载client.bundle.js文件,为了让Vue在浏览器端做后续接管。

访问http://localhost:3001/index还和第一步实现的效果一样,纯浏览器渲染,这里就不放截图了。

完整代码查看github

3. 服务端渲染,包含Ajax初始化数据

如果SSR需要初始化一些异步数据,那么流程就会变得复杂一些。

我们先提出几个问题:

  1. 服务端拿异步数据的步骤在哪做?
  2. 如何确定哪些组件需要获取异步数据?
  3. 获取到异步数据之后要如何塞回到组件内?

带着问题我们向下走,希望看完这篇文章的时候上面的问题你都找到了答案。

服务器端渲染和浏览器端渲染组件经过的生命周期是有区别的,在服务器端,只会经历beforeCreatecreated两个生命周期。因为SSR服务器直接吐出html字符串就好了,不会渲染DOM结构,所以不存在beforeMountmounted的,也不会对其进行更新,所以也就不存在beforeUpdateupdated等。

我们先来想一下,在纯浏览器渲染的Vue项目中,我们是怎么获取异步数据并渲染到组件中的?一般是在created或者mounted生命周期里发起异步请求,然后在成功回调里执行this.data = xxxVue监听到数据发生改变,走后面的Dom Diff,打patch,做DOM更新。

那么服务端渲染可不可以也这么做呢?答案是不行的

  1. mounted里肯定不行,因为SSR都没有mounted生命周期,所以在这里肯定不行。
  2. beforeCreate里发起异步请求是否可以呢,也是不行的。因为请求是异步的,可能还没有等接口返回,服务端就已经把html字符串拼接出来了。

所以,参考一下官方文档,我们可以得到以下思路:

  1. 在渲染前,要预先获取所有需要的异步数据,然后存到Vuexstore中。
  2. 在后端渲染时,通过Vuex将获取到的数据注入到相应组件中。
  3. store中的数据设置到window.__INITIAL_STATE__属性中。
  4. 在浏览器环境中,通过Vuexwindow.__INITIAL_STATE__里面的数据注入到相应组件中。

正常情况下,通过这几个步骤,服务端吐出来的html字符串相应组件的数据都是最新的,所以第4步并不会引起DOM更新,但如果出了某些问题,吐出来的html字符串没有相应数据,Vue也可以在浏览器端通过`Vuex注入数据,进行DOM更新。

更新后的目录结构:

- node_modules
- config
   - webpack.base.config.js
   - webpack.client.config.js
   - webpack.server.config.js
- src
   - components  
       - Bar.vue
       - Foo.vue
   - store             // 新增
       store.js
   - App.vue
   - app.js
   - entry-client.js
   - entry-server.js   
   - index.html
   - index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore

先来看一下store.js:

store/store.js

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const fetchBar = function() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('bar 组件返回 ajax 数据');
    }, 1000);
  });
};

function createStore() {
  const store = new Vuex.Store({
    state: {
      bar: ''
    },

    mutations: {
      'SET_BAR'(state, data) {
        state.bar = data;
      }
    },

    actions: {
      fetchBar({ commit }) {
        return fetchBar().then((data) => {
          commit('SET_BAR', data);
        }).catch((err) => {
          console.error(err);
        })
      }
    }
  });

  if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
  }
  
  return store;
}

export default createStore;

typeof window

如果不太了解Vuex,可以去Vuex官网先看一些基本概念。

vuex

这里fetchBar可以看成是一个异步请求,这里用setTimeout模拟。在成功回调中commit相应的mutation进行状态修改。

这里有一段关键代码:

if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
    console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
    store.replaceState(window.__INITIAL_STATE__);
}

因为store.js同样也会被打包到服务器运行的server.bundle.js中,所以运行环境不一定是浏览器,这里需要对window做判断,防止报错,同时如果有window.__INITIAL_STATE__属性,说明服务器已经把所有初始化需要的异步数据都获取完成了,要对store中的状态做一个替换,保证统一。

components/Bar.vue

<template>
  <div class="bar">
    <h1 @click="onHandleClick">Bar Component</h1>
    <h2>异步Ajax数据:</h2>
    <span>{{ msg }}</span>
  </div>
</template>

<script>
  const fetchInitialData = ({ store }) => {
    store.dispatch('fetchBar');
  };

  export default {
    asyncData: fetchInitialData,

    methods: {
      onHandleClick() {
        alert('bar');
      }
    },

    mounted() {
      // 因为服务端渲染只有 beforeCreate 和 created 两个生命周期,不会走这里
      // 所以把调用 Ajax 初始化数据也写在这里,是为了供单独浏览器渲染使用
      let store = this.$store;
      fetchInitialData({ store });
    },

    computed: {
      msg() {
        return this.$store.state.bar;
      }
    }
  }
</script>

<style>
.bar {
  background: bisque;
}
</style>

这里在Bar组件的默认导出对象中增加了一个方法asyncData,在该方法中会dispatch相应的action,进行异步数据获取。

需要注意的是,我在mounted中也写了获取数据的代码,这是为什么呢? 因为想要做到同构,代码单独在浏览器端运行,也应该是没有问题的,又由于服务器没有mounted生命周期,所以我写在这里就可以解决单独在浏览器环境使用也可以发起同样的异步请求去初始化数据。

components/Foo.vue

<template>
  <div class="foo">
    <h1 @click="onHandleClick">Foo Component</h1>
  </div>
</template>

<script>
export default {
  methods: {
    onHandleClick() {
      alert('foo');
    }
  },
}
</script>

<style>
.foo {
  background: yellowgreen;
}
</style>

这里我对两个组件都添加了一个点击事件,为的是证明在服务器吐出首页html后,后续的步骤都会被浏览器端的Vue接管,可以正常执行后面的操作。

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';

export function createApp() {
  const store = createStore();

  const app = new Vue({
    store,
    render: h => h(App)
  });

  return { app, store, App };
}

在建立根组件的时候,要把Vuex的store传进去,同时要返回,后续会用到。

最后来看一下entry-server.js,关键步骤在这里:

entry-server.js

import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, App } = createApp();

    let components = App.components;
    let asyncDataPromiseFns = [];
  
    Object.values(components).forEach(component => {
      if (component.asyncData) {
        asyncDataPromiseFns.push(component.asyncData({ store }));
      }
    });
  
    Promise.all(asyncDataPromiseFns).then((result) => {
      // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
      context.state = store.state;
  
      console.log(222);
      console.log(store.state);
      console.log(context.state);
      console.log(context);
  
      resolve(app);
    }, reject);
  });
}

我们通过导出的App拿到了所有它下面的components,然后遍历,找出哪些componentasyncData方法,有的话调用并传入store,该方法会返回一个Promise,我们使用Promise.all等所有的异步方法都成功返回,才resolve(app)

context.state = store.state作用是,当使用createBundleRenderer时,如果设置了template选项,那么会把context.state的值作为window.__INITIAL_STATE__自动插入到模板html中。

这里需要大家多思考一下,弄清楚整个服务端渲染的逻辑。

如何运行:

yarn run build:client
yarn run build:server

yarn start

最终效果截图:

服务端渲染:打开http://localhost:3000/index

server-remder-ajax

可以看到window.__INITIAL_STATE__被自动插入了。

我们来对比一下SSR到底对加载性能有什么影响吧。

服务端渲染时performance截图

sercer_render_ajax_performance

纯浏览器端渲染时performance截图

client_render_ajax_performance

同样都是在fast 3G网络模式下,纯浏览器端渲染首屏加载花费时间2.9s,因为client.js加载就花费了2.27s,因为没有client.js就没有Vue,也就没有后面的东西了。

服务端渲染首屏时间花费0.8s,虽然client.js加载扔花费2.27s,但是首屏已经不需要它了,它是为了让Vue在浏览器端进行后续接管。

从这我们可以真正的看到,服务端渲染对于提升首屏的响应速度是很有作用的。

当然有的同学可能会问,在服务端渲染获取初始ajax数据时,我们还延时了1s,在这个时间用户也是看不到页面的。没错,接口的时间我们无法避免,就算是纯浏览器渲染,首页该调接口还是得调,如果接口响应慢,那么纯浏览器渲染看到完整页面的时间会更慢。

完整代码查看github

4. 使用serverBundle和clientManifest进行优化

前面我们创建服务端renderer的方法是:

const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
  template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});

serverBundle我们用的是打包出的server.bundle.js文件。这样做的话,在每次编辑过应用程序源代码之后,都必须停止并重启服务。这在开发过程中会影响开发效率。此外,Node.js 本身不支持 source map。

vue-server-renderer 提供一个名为 createBundleRenderer 的 API,用于处理此问题,通过使用 webpack 的自定义插件,server bundle 将生成为可传递到 bundle renderer 的特殊 JSON 文件。所创建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下优点:

  • 内置的 source map 支持(在 webpack 配置中使用 devtool: 'source-map'
  • 在开发环境甚至部署过程中热重载(通过读取更新后的 bundle,然后重新创建 renderer 实例)
  • 关键 CSS(critical CSS) 注入(在使用 *.vue 文件时):自动内联在渲染过程中用到的组件所需的CSS。更多细节请查看 CSS 章节。
  • 使用 clientManifest 进行资源注入:自动推断出最佳的预加载(preload)和预取(prefetch)指令,以及初始渲染所需的代码分割 chunk

preloadprefetch有不了解的话可以自行查一下它们的作用哈。

那么我们来修改webpack配置:

webpack.client.config.js

const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  entry: {
    client: path.resolve(__dirname, '../src/entry-client.js')
  },

  plugins: [
    new VueSSRClientPlugin(),   // 新增
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.html'),
      filename: 'index.html'
    })
  ]
});

webpack.server.config.js

const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');

module.exports = merge(base, {
  target: 'node',
   // 对 bundle renderer 提供 source map 支持
  devtool: '#source-map',
  entry: {
    server: path.resolve(__dirname, '../src/entry-server.js')
  },
  externals: [nodeExternals()],     // 新增
  output: {
    libraryTarget: 'commonjs2'
  },
  plugins: [
    new VueSSRServerPlugin(),   // 这个要放到第一个写,否则 CopyWebpackPlugin 不起作用,原因还没查清楚
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, '../src/index.ssr.html'),
      filename: 'index.ssr.html',
      files: {
        js: 'client.bundle.js'
      },
      excludeChunks: ['server']
    })
  ]
});

因为是服务端引用模块,所以不需要打包node_modules中的依赖,直接在代码中require引用就好,所以配置externals: [nodeExternals()]

两个配置文件会分别生成vue-ssr-client-manifest.jsonvue-ssr-server-bundle.json。作为createBundleRenderer的参数。

来看server.js

server.js

const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');

const renderer = createBundleRenderer(serverBundle, {
  runInNewContext: false,
  template: template,
  clientManifest: clientManifest
});

效果和第三步就是一样的啦,就不截图了,完整代码查看github

5. 配置一个完整的基于Vue + VueRouter + Vuex的SSR

这里和第四步不一样的是引入了vue-router,更接近于实际开发项目。

src下新增router目录。

router/index.js

import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';

Vue.use(Router);

function createRouter() {
  const routes = [
    {
      path: '/bar',
      component: Bar
    },
    {
      path: '/foo',
      component: () => import('../components/Foo.vue')   // 异步路由
    }
  ];

  const router = new Router({
    mode: 'history',
    routes
  });

  return router;
}

export default createRouter;

这里我们把Foo组件作为一个异步组件引入,做成按需加载。

app.js中引入router,并导出:

app.js

import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';

export function createApp() {
  const store = createStore();
  const router = createRouter();

  const app = new Vue({
    router,
    store,
    render: h => h(App)
  });

  return { app, store, router, App };
}

修改App.vue引入路由组件:

App.vue

<template>
  <div id="app">
    <router-link to="/bar">Goto Bar</router-link> 
    <router-link to="/foo">Goto Foo</router-link> 
    <router-view></router-view>
  </div>
</template>

<script>
export default {
  beforeCreate() {
    console.log('App.vue beforeCreate');
  },

  created() {
    console.log('App.vue created');
  },

  beforeMount() {
    console.log('App.vue beforeMount');
  },

  mounted() {
    console.log('App.vue mounted');
  }
}
</script>

最重要的修改在entry-server.js中,

entry-server.js

import { createApp } from './app.js';

export default context => {
  return new Promise((resolve, reject) => {
    const { app, store, router, App } = createApp();

    router.push(context.url);

    router.onReady(() => {
      const matchedComponents = router.getMatchedComponents();

      console.log(context.url)
      console.log(matchedComponents)

      if (!matchedComponents.length) {
        return reject({ code: 404 });
      }

      Promise.all(matchedComponents.map(component => {
        if (component.asyncData) {
          return component.asyncData({ store });
        }
      })).then(() => {
        // 当使用 template 时,context.state 将作为 window.__INITIAL_STATE__ 状态,自动嵌入到最终的 HTML 中
        context.state = store.state;

        // 返回根组件
        resolve(app);
      });
    }, reject);
  });
}

这里前面提到的context就起了大作用,它将用户访问的url地址传进来,供vue-router使用。因为有异步组件,所以在router.onReady的成功回调中,去找该url路由所匹配到的组件,获取异步数据那一套还和前面的一样。

于是,我们就完成了一个基本完整的基于Vue + VueRouter + VuexSSR配置,完成代码查看github

最终效果演示:

访问http://localhost:3000/bar

ssr-router

完整代码查看github

后续

上面我们通过五个步骤,完成了从纯浏览器渲染到完整服务端渲染的同构,代码既可以运行在浏览器端,也可以运行在服务器端。那么,回过头来我们在看一下是否有优化的空间,又或者有哪些扩展的思考。

1. 优化
  • 我们目前是使用renderToString方法,完全生成html后,才会向客户端返回,如果使用renderToStream,应用bigpipe技术可以向浏览器持续不断的返回一个流,那么文件的加载浏览器可以尽早的显示一些东西出来。
const stream = renderer.renderToStream(context)

返回的值是 Node.js stream

let html = ''

stream.on('data', data => {
  html += data.toString()
})

stream.on('end', () => {
  console.log(html) // 渲染完成
})

stream.on('error', err => {
  // handle error...
})

在流式渲染模式下,当 renderer 遍历虚拟 DOM 树(virtual DOM tree)时,会尽快发送数据。这意味着我们可以尽快获得"第一个 chunk",并开始更快地将其发送给客户端。

然而,当第一个数据 chunk 被发出时,子组件甚至可能不被实例化,它们的生命周期钩子也不会被调用。这意味着,如果子组件需要在其生命周期钩子函数中,将数据附加到渲染上下文(render context),当流(stream)启动时,这些数据将不可用。这是因为,大量上下文信息(context information)(如头信息(head information)或内联关键 CSS(inline critical CSS))需要在应用程序标记(markup)之前出现,我们基本上必须等待流(stream)完成后,才能开始使用这些上下文数据。

因此,如果你依赖由组件生命周期钩子函数填充的上下文数据,则不建议使用流式传输模式。

  • webpack优化

webpack优化又是一个大的话题了,这里不展开讨论,感兴趣的同学可以自行查找一些资料,后续我也可能会专门写一篇文章来讲webpack优化。

2. 思考
  • 是否必须使用vuex

答案是不用。Vuex只是为了帮助你实现一套数据存储、更新、获取的机制,入股你不用Vuex,那么你就必须自己想一套方案可以将异步获取到的数据存起来,并且在适当的时机将它注入到组件内,有一些文章提出了一些方案,我会放到参考文章里,大家可以阅读一下。

  • 是否使用SSR就一定好?

这个也是不一定的,任何技术都有使用场景。SSR可以帮助你提升首页加载速度,优化搜索引擎SEO,但同时由于它需要在node中渲染整套Vue的模板,会占用服务器负载,同时只会执行beforeCreatecreated两个生命周期,对于一些外部扩展库需要做一定处理才可以在SSR中运行等等。

结语

本文通过五个步骤,从纯浏览器端渲染开始,到配置一个完整的基于Vue + vue-router + Vuex的SSR环境,介绍了很多新的概念,也许你看完一遍不太理解,那么结合着源码,去自己手敲几遍,然后再来看几遍文章,相信你一定可以掌握SSR

最后,本文所有源代码都放在我的github上,如果对你有帮助的话,就来点一个赞吧~~

欢迎关注我的公众号

微信公众号

参考链接

查看原文

飘香豆腐 赞了文章 · 2020-11-25

「 giao-js 」用js写一个js解释器

image

前言

在这篇文章中,我们将通过 JS 构建我们自己的 JS 解释器,用 JS 写 JS,这听起来很奇怪,尽管如此,这样做我们将更熟悉 JS,也可以学习 JS 引擎是如何工作的!

什么是解释器 (Interpreter) ?

解释器是在运行时运行的语言求值器,它动态地执行程序的源代码。
解释器解析源代码,从源代码生成 AST(抽象语法树),遍历 AST 并逐个计算它们。

解释器 (Interpreter) 工作原理

Interpreter

  • 词法分析 (Tokenization)
  • 语法解析 (Parsing)
  • 求值 (Evaluating)

词法分析 (Tokenization)

将源代码分解并组织成一组有意义的单词,这一过程即为词法分析(Token)。

在英语中,当我们遇到这样一个语句时:

Javascript is the best language in the world

我们会下意识地把句子分解成一个个单词:

+----------------------------------------------------------+
| Javascript | is | the | best | language | in |the |world |
+----------------------------------------------------------+

这是分析和理解句子的第一阶段。

词法分析是由词法分析器完成的,词法分析器会扫描(scanning)代码,提取词法单元。

var a = 1;

[
  ("var": "keyword"),
  ("a": "identifier"),
  ("=": "assignment"),
  ("1": "literal"),
  (";": "separator"),
];

词法分析器将代码分解成 Token 后,会将 Token 传递给解析器进行解析,我们来看下解析阶段是如何工作的。

语法解析 (Parsing)

将词法分析阶段生成的 Token 转换为抽象语法树(Abstract Syntax Tree),这一过程称之为语法解析(Parsing)。

在英语中,Javascript is the best language 被分解为以下单词:

+------------------------------------------+
| Javascript | is | the | best | language  |
+------------------------------------------+

这样我们就可以挑选单词并形成语法结构:

"Javascript": Subject
"is the best language": Predicate
"language": Object

Javascript 在语法中是一个主语名词,其余的是一个没有什么意义的句子叫做谓语,language 是动作的接受者,也就是宾语。结构是这样的:

Subject(Noun) -> Predicate -> Object

语法解析是由语法解析器完成的,它会将上一步生成的 Token,根据语法规则,转为抽象语法树(AST)。

{
  type: "Program",
  body: [
    {
      type: "VariableDeclaration",
      declarations: [
        {
          type: "VariableDeclarator",
          id: {
            type: "Identifier",
            name: "sum"
          },
          init: {
            type: "Literal",
            value: 30,
            raw: "30"
          }
        }
      ],
      kind: "var"
    }
  ],
}

求值阶段 (Evaluating)

解释器将遍历 AST 并计算每个节点。- 求值阶段
1 + 2
|
    |
    v
+---+  +---+
| 1 |  | 2 |
+---+  +---+
  \     /
   \   /
    \ /
   +---+
   | + |
   +---+
{
    lhs: 1,
    op: '+'.
    rhs: 2
}

解释器解析 Ast,得到 LHS 节点,接着收集到操作符(operator)节点+,+操作符表示需要进行一次加法操作,它必须有第二个节点来进行加法操作.接着他收集到 RHS 节点。它收集到了有价值的信息并执行加法得到了结果,3。

{
  type: "Program",
  body: [
    {
      type: "ExpressionStatement",
      expression: {
        type: "BinaryExpression",
        left: {
          type: "Literal",
          value: 1,
          raw: "1"
        },
        operator: "+",
        right: {
          type: "Literal",
          value: 2,
          raw: "2"
        }
      }
    }
  ],
}

实践

前面我们已经介绍了解释器的工作原理,接下来我们来动动手松松筋骨吧,实现一个 Mini Js Interpreter~

实践准备

  • Acorn.js
A tiny, fast JavaScript parser, written completely in JavaScript. 一个完全使用 javascript 实现的,小型且快速的 javascript 解析器

本次实践我们将使用 acorn.js ,它会帮我们进行词法分析,语法解析并转换为抽象语法树。

Webpack/Rollup/Babel(@babel/parser) 等第三方库也是使用 acorn.js 作为自己 Parser 的基础库。(站在巨人的肩膀上啊!)

  • The Estree Spec

最开始 Mozilla JS Parser API 是 Mozilla 工程师在 Firefox 中创建的 SpiderMonkey 引擎输出 JavaScript AST 的规范文档,文档所描述的格式被用作操作 JAvaScript 源代码的通用语言。

随着 JavaScript 的发展,更多新的语法被加入,为了帮助发展这种格式以跟上 JavaScript 语言的发展。The ESTree Spec 就诞生了,作为参与构建和使用这些工具的人员的社区标准。

acorn.js parse 返回值符合 ESTree spec 描述的 AST 对象,这里我们使用@types/estree 做类型定义。

  • Jest

号称令人愉快的 JavaScript 测试...我们使用它来进行单元测试.

  • Rollup

Rollup 是一个 JavaScript 模块打包器,我们使用它来打包,以 UMD 规范对外暴露模块。

项目初始化

// visitor.ts 创建一个Visitor类,并提供一个方法操作ES节点。
import * as ESTree from "estree";
class Visitor {
  visitNode(node: ESTree.Node) {
    // ...
  }
}
export default Visitor;
// interpreter.ts 创建一个Interpreter类,用于运行ES节点树。
// 创建一个Visitor实例,并使用该实例来运行ESTree节点
import Visitor from "./visitor";
import * as ESTree from "estree";
class Interpreter {
  private visitor: Visitor;
  constructor(visitor: Visitor) {
    this.visitor = visitor;
  }
  interpret(node: ESTree.Node) {
    this.visitor.visitNode(node);
  }
}
export default Interpreter;
// vm.ts 对外暴露run方法,并使用acorn code->ast后,交给Interpreter实例进行解释。
const acorn = require("acorn");
import Visitor from "./visitor";
import Interpreter from "./interpreter";

const jsInterpreter = new Interpreter(new Visitor());

export function run(code: string) {
  const root = acorn.parse(code, {
    ecmaVersion: 8,
    sourceType: "script",
  });
  return jsInterpreter.interpret(root);
}

实践第 1 弹: 1+1= ?

我们这节来实现 1+1 加法的解释。首先我们通过AST explorer,看看 1+1 这段代码转换后的 AST 结构。

1+1 ast

我们可以看到这段代码中存在 4 种节点类型,下面我们简单的介绍一下它们:

Program

根节点,即代表一整颗抽象语法树,body 属性是一个数组,包含了多个 Statement 节点。

interface Program {
  type: "Program";
  sourceType: "script" | "module";
  body: Array<Directive | Statement | ModuleDeclaration>;
  comments?: Array<Comment>;
}

ExpressionStatement

表达式语句节点,expression 属性指向一个表达式节点对象

interface ExpressionStatement {
  type: "ExpressionStatement";
  expression: Expression;
}

BinaryExpression

二元运算表达式节点,left 和 right 表示运算符左右的两个表达式,operator 表示一个二元运算符。
本节实现的重点,简单理解,我们只要拿到 operator 操作符的类型并实现,然后对 left,right 值进行求值即可。

interface BinaryExpression {
  type: "BinaryExpression";
  operator: BinaryOperator;
  left: Expression;
  right: Expression;
}

Literal

字面量,这里不是指 [] 或者 {} 这些,而是本身语义就代表了一个值的字面量,如 1,“hello”, true 这些,还有正则表达式,如 /\d?/。

type Literal = SimpleLiteral | RegExpLiteral;

interface SimpleLiteral {
  type: "Literal";
  value: string | boolean | number | null;
  raw?: string;
}

interface RegExpLiteral {
  type: "Literal";
  value?: RegExp | null;
  regex: {
    pattern: string;
    flags: string;
  };
  raw?: string;
}

废话少说,开撸!!!

// standard/es5.ts 实现以上节点方法

import Scope from "../scope";
import * as ESTree from "estree";
import { AstPath } from "../types/index";

const es5 = {
  // 根节点的处理很简单,我们只要对它的body属性进行遍历,然后访问该节点即可。
  Program(node: ESTree.Program) {
    node.body.forEach((bodyNode) => this.visitNode(bodyNode));
  },
  // 表达式语句节点的处理,同样访问expression 属性即可。
  ExpressionStatement(node: ESTree.ExpressionStatement>) {
    return this.visitNode(node.expression);
  },
  // 字面量节点处理直接求值,这里对正则表达式类型进行了特殊处理,其他类型直接返回value值即可。
  Literal(node: ESTree.Literal>) {
    if ((<ESTree.RegExpLiteral>node).regex) {
      const { pattern, flags } = (<ESTree.RegExpLiteral>node).regex;
      return new RegExp(pattern, flags);
    } else return node.value;
  },
  // 二元运算表达式节点处理
  // 对left/node两个节点(Literal)进行求值,然后实现operator类型运算,返回结果。
  BinaryExpression(node: ESTree.BinaryExpression>) {
    const leftNode = this.visitNode(node.left);
    const operator = node.operator;
    const rightNode = this.visitNode(node.right);
    return {
      "+": (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,
    }[operator](leftNode, rightNode);
  },
};
export default es5;
// visitor.ts
import Scope from "./scope";
import * as ESTree from "estree";
import es5 from "./standard/es5";

const VISITOR = {
  ...es5,
};
class Visitor {
  // 实现访问节点方法,通过节点类型访问对应的节点方法
  visitNode(node: ESTree.Node) {
    return {
      visitNode: this.visitNode,
      ...VISITOR,
    }[node.type](node);
  }
}
export default Visitor;

就这样,普通的二元运算就搞定啦!!!

实践第 2 弹: 怎么找到变量?

Javascript 的作用域与作用域链的概念想必大家都很熟悉了,这里就不再啰嗦了~

是的,我们需要通过实现作用域来访问变量,实现作用域链来搜寻标识符。

在这之前,我们先实现 Variable 类,实现变量的存取方法。

// variable.ts
export enum Kind {
  var = "var",
  let = "let",
  const = "const",
}
export type KindType = "var" | "let" | "const";
export class Variable {
  private _value: any;
  constructor(public kind: Kind, val: any) {
    this._value = val;
  }
  get value() {
    return this._value;
  }
  set value(val: any) {
    this._value = val;
  }
}
import { Variable, Kind, KindType } from "./variable";

class Scope {
  // 父作用域
  private parent: Scope | null;
  // 当前作用域
  private targetScope: { [key: string]: any };
  constructor(public readonly type, parent?: Scope) {
    this.parent = parent || null;
    this.targetScope = new Map();
  }
  // 是否已定义
  private hasDefinition(rawName: string): boolean {
    return Boolean(this.search(rawName));
  }
  // var类型变量定义
  public defineVar(rawName: string, value: any) {
    let scope: Scope = this;
    // 如果不是全局作用域且不是函数作用域,找到全局作用域,存储变量
    // 这里就是我们常说的Hoisting (变量提升)
    while (scope.parent && scope.type !== "function") {
      scope = scope.parent;
    }
    // 存储变量
    scope.targetScope.set(rawName, new Variable(Kind.var, value));
  }
  // let类型变量定义
  public defineLet(rawName: string, value: any) {
    this.targetScope.set(rawName, new Variable(Kind.let, value));
  }
  // const类型变量定义
  public defineConst(rawName: string, value: any) {
    this.targetScope.set(rawName, new Variable(Kind.const, value));
  }
  // 作用域链实现,向上查找标识符
  public search(rawName: string): Variable | null {
    if (this.targetScope.get(rawName)) {
      return this.targetScope.get(rawName);
    } else if (this.parent) {
      return this.parent.search(rawName);
    } else {
      return null;
    }
  }
  // 变量声明方法,变量已定义则抛出语法错误异常
  public declare(kind: Kind | KindType, rawName: string, value: any) {
    if (this.hasDefinition(rawName)) {
      console.error(
        `Uncaught SyntaxError: Identifier '${rawName}' has already been declared`
      );
      return true;
    }
    return {
      [Kind.var]: () => this.defineVar(rawName, value),
      [Kind.let]: () => this.defineLet(rawName, value),
      [Kind.const]: () => this.defineConst(rawName, value),
    }[kind]();
  }
}

export default Scope;

以上就是变量对象,作用域及作用域链的基础实现了,接下来我们就可以定义及访问变量了。

实践第 3 弹: var age = 18

var

从语法树中我们可以看到三个陌生的节点类型,来看看它们分别代表什么意思:

VariableDeclaration

变量声明,kind 属性表示是什么类型的声明,因为 ES6 引入了 const/let。
declarations 表示声明的多个描述,因为我们可以这样:let a = 1, b = 2;。

interface VariableDeclaration {
  type: "VariableDeclaration";
  declarations: Array<VariableDeclarator>;
  kind: "var" | "let" | "const";
}

VariableDeclarator

变量声明的描述,id 表示变量名称节点,init 表示初始值的表达式,可以为 null。

interface VariableDeclarator {
  type: "VariableDeclarator";
  id: Pattern;
  init?: Expression | null;
}

Identifier

顾名思义,标识符节点,我们写 JS 时定义的变量名,函数名,属性名,都归为标识符。

interface Identifier {
  type: "Identifier";
  name: string;
}

了解了对应节点的含义后,我们来进行实现:

// standard/es5.ts 实现以上节点方法

import Scope from "../scope";
import * as ESTree from "estree";

type AstPath<T> = {
  node: T;
  scope: Scope;
};

const es5 = {
  // ...
  // 这里我们定义了astPath,新增了scope作用域参数
  VariableDeclaration(astPath: AstPath<ESTree.VariableDeclaration>) {
    const { node, scope } = astPath;
    const { declarations, kind } = node;
    // 上面提到,生声明可能存在多个描述(let a = 1, b = 2;),所以我们这里对它进行遍历:
    // 这里遍历出来的每个item是VariableDeclarator节点
    declarations.forEach((declar) => {
      const { id, init } = <ESTree.VariableDeclarator>declar;
      // 变量名称节点,这里拿到的是age
      const key = (<ESTree.Identifier>id).name;
      // 判断变量是否进行了初始化 ? 查找init节点值(Literal类型直接返回值:18) : 置为undefined;
      const value = init ? this.visitNode(init, scope) : undefined;
      // 根据不同的kind(var/const/let)声明进行定义,即var age = 18
      scope.declare(kind, key, value);
    });
  },
  // 标识符节点,我们只要通过访问作用域,访问该值即可。
  Identifier(astPath: AstPath<ESTree.Identifier>) {
    const { node, scope } = astPath;
    const name = node.name;
    // walk identifier
    // 这个例子中查找的是age变量
    const variable = scope.search(name);
    // 返回的是定义的变量对象(age)的值,即18
    if (variable) return variable.value;
  },
};
export default es5;

实践第 4 弹: module.exports = 6

我们先来看看 module.exports = 6 对应的 AST。

module-exports

从语法树中我们又看到两个陌生的节点类型,来看看它们分别代表什么意思:

AssignmentExpression

赋值表达式节点,operator 属性表示一个赋值运算符,left 和 right 是赋值运算符左右的表达式。

interface AssignmentExpression {
  type: "AssignmentExpression";
  operator: AssignmentOperator;
  left: Pattern | MemberExpression;
  right: Expression;
}

MemberExpression

成员表达式节点,即表示引用对象成员的语句,object 是引用对象的表达式节点,property 是表示属性名称,computed 如果为 false,是表示 . 来引用成员,property 应该为一个 Identifier 节点,如果 computed 属性为 true,则是 [] 来进行引用,即 property 是一个 Expression 节点,名称是表达式的结果值。

interface MemberExpression {
  type: "MemberExpression";
  object: Expression | Super;
  property: Expression;
  computed: boolean;
  optional: boolean;
}

我们先来定义 module.exports 变量。

import Scope from "./scope";
import Visitor from "./visitor";
import * as ESTree from "estree";
class Interpreter {
  private scope: Scope;
  private visitor: Visitor;
  constructor(visitor: Visitor) {
    this.visitor = visitor;
  }
  interpret(node: ESTree.Node) {
    this.createScope();
    this.visitor.visitNode(node, this.scope);
    return this.exportResult();
  }
  createScope() {
    // 创建全局作用域
    this.scope = new Scope("root");
    // 定义module.exports
    const $exports = {};
    const $module = { exports: $exports };
    this.scope.defineConst("module", $module);
    this.scope.defineVar("exports", $exports);
  }
  // 模拟commonjs,对外暴露结果
  exportResult() {
    // 查找module变量
    const moduleExport = this.scope.search("module");
    // 返回module.exports值
    return moduleExport ? moduleExport.value.exports : null;
  }
}
export default Interpreter;

ok,下面我们来实现以上节点函数~

// standard/es5.ts 实现以上节点方法

import Scope from "../scope";
import * as ESTree from "estree";

type AstPath<T> = {
  node: T;
  scope: Scope;
};

const es5 = {
  // ...
  // 这里我们定义了astPath,新增了scope作用域参数
  MemberExpression(astPath: AstPath<ESTree.MemberExpression>) {
    const { node, scope } = astPath;
    const { object, property, computed } = node;
    // property 是表示属性名称,computed 如果为 false,property 应该为一个 Identifier 节点,如果 computed 属性为 true,即 property 是一个 Expression 节点
    // 这里我们拿到的是exports这个key值,即属性名称
    const prop = computed
      ? this.visitNode(property, scope)
      : (<ESTree.Identifier>property).name;
    // object 表示对象,这里为module,对module进行节点访问
    const obj = this.visitNode(object, scope);
    // 访问module.exports值
    return obj[prop];
  },
  // 赋值表达式节点
  (astPath: AstPath<ESTree.>) {
    const { node, scope } = astPath;
    const { left, operator, right } = node;
    let assignVar;
    // LHS 处理
    if (left.type === "Identifier") {
      // 标识符类型 直接查找
      const value = scope.search(left.name);
      assignVar = value;
    } else if (left.type === "MemberExpression") {
      // 成员表达式类型,处理方式跟上面差不多,不同的是这边需要自定义一个变量对象的实现
      const { object, property, computed } = left;
      const obj = this.visitNode(object, scope);
      const key = computed
        ? this.visitNode(property, scope)
        : (<ESTree.Identifier>property).name;
      assignVar = {
        get value() {
          return obj[key];
        },
        set value(v) {
          obj[key] = v;
        },
      };
    }
    // RHS
    // 不同操作符处理,查询到right节点值,对left节点进行赋值。
    return {
      "=": (v) => {
        assignVar.value = v;
        return v;
      },
      "+=": (v) => {
        const value = assignVar.value;
        assignVar.value = v + value;
        return assignVar.value;
      },
      "-=": (v) => {
        const value = assignVar.value;
        assignVar.value = value - v;
        return assignVar.value;
      },
      "*=": (v) => {
        const value = assignVar.value;
        assignVar.value = v * value;
        return assignVar.value;
      },
      "/=": (v) => {
        const value = assignVar.value;
        assignVar.value = value / v;
        return assignVar.value;
      },
      "%=": (v) => {
        const value = assignVar.value;
        assignVar.value = value % v;
        return assignVar.value;
      },
    }[operator](this.visitNode(right, scope));
  },
};
export default es5;

ok,实现完毕,是时候验证一波了,上 jest 大法。

// __test__/es5.test.ts

import { run } from "../src/vm";
describe("giao-js es5", () => {
  test("assign", () => {
    expect(
      run(`
      module.exports = 6;
    `)
    ).toBe(6);
  });
}

jest

实践第 5 弹: for 循环

var result = 0;
for (var i = 0; i < 5; i++) {
  result += 2;
}
module.exports = result;

for-loop

到这一弹大家都发现了,不同的语法其实对应的就是不同的树节点,我们只要实现对应的节点函数即可.我们先来看看这几个陌生节点的含义.

ForStatement

for 循环语句节点,属性 init/test/update 分别表示了 for 语句括号中的三个表达式,初始化值,循环判断条件,每次循环执行的变量更新语句(init 可以是变量声明或者表达式)。
这三个属性都可以为 null,即 for(;;){}。
body 属性用以表示要循环执行的语句。

interface ForStatement {
  type: "ForStatement";
  init?: VariableDeclaration | Expression | null;
  test?: Expression | null;
  update?: Expression | null;
  body: Statement;
}

UpdateExpression

update 运算表达式节点,即 ++/--,和一元运算符类似,只是 operator 指向的节点对象类型不同,这里是 update 运算符。

interface UpdateExpression {
  type: "UpdateExpression";
  operator: UpdateOperator;
  argument: Expression;
  prefix: boolean;
}

BlockStatement

块语句节点,举个例子:if (...) { // 这里是块语句的内容 },块里边可以包含多个其他的语句,所以有一个 body 属性,是一个数组,表示了块里边的多个语句。

interface BlockStatement {
  0;
  type: "BlockStatement";
  body: Array<Statement>;
  innerComments?: Array<Comment>;
}

废话少说,盘它!!!

// standard/es5.ts 实现以上节点方法

import Scope from "../scope";
import * as ESTree from "estree";

type AstPath<T> = {
  node: T;
  scope: Scope;
};

const es5 = {
  // ...
  // for 循环语句节点
  ForStatement(astPath: AstPath<ESTree.ForStatement>) {
    const { node, scope } = astPath;
    const { init, test, update, body } = node;
    // 这里需要注意的是需要模拟创建一个块级作用域
    // 前面Scope类实现,var声明在块作用域中会被提升,const/let不会
    const forScope = new Scope("block", scope);
    for (
      // 初始化值
      // VariableDeclaration
      init ? this.visitNode(init, forScope) : null;
      // 循环判断条件(BinaryExpression)
      // 二元运算表达式,之前已实现,这里不再细说
      test ? this.visitNode(test, forScope) : true;
      // 变量更新语句(UpdateExpression)
      update ? this.visitNode(update, forScope) : null
    ) {
      // BlockStatement
      this.visitNode(body, forScope);
    }
  },
  // update 运算表达式节点
  // update 运算表达式节点,即 ++/--,和一元运算符类似,只是 operator 指向的节点对象类型不同,这里是 update 运算符。
  UpdateExpression(astPath: AstPath<ESTree.UpdateExpression>) {
    const { node, scope } = astPath;
    // update 运算符,值为 ++ 或 --,配合 update 表达式节点的 prefix 属性来表示前后。
    const { prefix, argument, operator } = node;
    let updateVar;
    // 这里需要考虑参数类型还有一种情况是成员表达式节点
    // 例: for (var query={count:0}; query.count < 8; query.count++)
    // LHS查找
    if (argument.type === "Identifier") {
      // 标识符类型 直接查找
      const value = scope.search(argument.name);
      updateVar = value;
    } else if (argument.type === "MemberExpression") {
      // 成员表达式的实现在前面实现过,这里不再细说,一样的套路~
      const { object, property, computed } = argument;
      const obj = this.visitNode(object, scope);
      const key = computed
        ? this.visitNode(property, scope)
        : (<ESTree.Identifier>property).name;
      updateVar = {
        get value() {
          return obj[key];
        },
        set value(v) {
          obj[key] = v;
        },
      };
    }
    return {
      "++": (v) => {
        const result = v.value;
        v.value = result + 1;
        // preifx? ++i: i++;
        return prefix ? v.value : result;
      },
      "--": (v) => {
        const result = v.value;
        v.value = result - 1;
        // preifx? --i: i--;
        return prefix ? v.value : result;
      },
    }[operator](updateVar);
  },
  // 块语句节点
  // 块语句的实现很简单,模拟创建一个块作用域,然后遍历body属性进行访问即可。
  BlockStatement(astPath: AstPath<ESTree.BlockStatement>) {
    const { node, scope } = astPath;
    const blockScope = new Scope("block", scope);
    const { body } = node;
    body.forEach((bodyNode) => {
      this.visitNode(bodyNode, blockScope);
    });
  },
};
export default es5;

上 jest 大法验证一哈~

test("test for loop", () => {
  expect(
    run(`
      var result = 0;
      for (var i = 0; i < 5; i++) {
        result += 2;
      }
      module.exports = result;
    `)
  ).toBe(10);
});

for-loop-jest

你以为这样就结束了吗? 有没有想到还有什么情况没处理? for 循环的中断语句呢?

var result = 0;
for (var i = 0; i < 5; i++) {
  result += 2;
  break; // break,continue,return
}
module.exports = result;

感兴趣的小伙伴可以自己动手试试,或者戳源码地址

结语

giao-js目前只实现了几个语法,本文只是提供一个思路。

有兴趣的同学可以查看完整代码

觉得有帮助到你的话,点个 star 支持下作者 ❤️ ~

参考

bramblex/jsjs

使用 Acorn 来解析 JavaScript

Build a JS Interpreter in JavaScript Using Acorn as a Parser

查看原文

赞 16 收藏 11 评论 0

飘香豆腐 收藏了文章 · 2020-11-19

Go mod 使用

go modules 是 golang 1.11 新加的特性。现在1.12 已经发布了,是时候用起来了。Modules官方定义为:

模块是相关Go包的集合。modules是源代码交换和版本控制的单元。 go命令直接支持使用modules,包括记录和解析对其他模块的依赖性。modules替换旧的基于GOPATH的方法来指定在给定构建中使用哪些源文件。

如何使用 Modules ?

  1. 把 golang 升级到 1.11(现在1.12 已经发布了,建议使用1.12)
  2. 设置 GO111MODULE

GO111MODULE

GO111MODULE 有三个值:off, onauto(默认值)

  • GO111MODULE=off,go命令行将不会支持module功能,寻找依赖包的方式将会沿用旧版本那种通过vendor目录或者GOPATH模式来查找。
  • GO111MODULE=on,go命令行会使用modules,而一点也不会去GOPATH目录下查找。
  • GO111MODULE=auto,默认值,go命令行将会根据当前目录来决定是否启用module功能。这种情况下可以分为两种情形:

    • 当前目录在GOPATH/src之外且该目录包含go.mod文件
    • 当前文件在包含go.mod文件的目录下面。
当modules 功能启用时,依赖包的存放位置变更为$GOPATH/pkg,允许同一个package多个版本并存,且多个项目可以共享缓存的 module。

go mod

golang 提供了 go mod命令来管理包。

go mod 有以下命令:

命令说明
downloaddownload modules to local cache(下载依赖包)
editedit go.mod from tools or scripts(编辑go.mod
graphprint module requirement graph (打印模块依赖图)
initinitialize new module in current directory(在当前目录初始化mod)
tidyadd missing and remove unused modules(拉取缺少的模块,移除不用的模块)
vendormake vendored copy of dependencies(将依赖复制到vendor下)
verifyverify dependencies have expected content (验证依赖是否正确)
whyexplain why packages or modules are needed(解释为什么需要依赖)

如何在项目中使用

示例一:创建一个新项目

  1. GOPATH 目录之外新建一个目录,并使用go mod init 初始化生成go.mod 文件
➜  ~ mkdir hello
➜  ~ cd hello
➜  hello go mod init hello
go: creating new go.mod: module hello
➜  hello ls
go.mod
➜  hello cat go.mod
module hello

go 1.12
go.mod文件一旦创建后,它的内容将会被go toolchain全面掌控。go toolchain会在各类命令执行时,比如go get、go build、go mod等修改和维护go.mod文件。

go.mod 提供了module, requirereplaceexclude 四个命令

  • module 语句指定包的名字(路径)
  • require 语句指定的依赖项模块
  • replace 语句可以替换依赖项模块
  • exclude 语句可以忽略依赖项模块
  1. 添加依赖

新建一个 server.go 文件,写入以下代码:

package main

import (
    "net/http"
    
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

执行 go run server.go 运行代码会发现 go mod 会自动查找依赖自动下载:

$ go run server.go
go: finding github.com/labstack/echo v3.3.10+incompatible
go: downloading github.com/labstack/echo v3.3.10+incompatible
go: extracting github.com/labstack/echo v3.3.10+incompatible
go: finding github.com/labstack/gommon/color latest
go: finding github.com/labstack/gommon/log latest
go: finding github.com/labstack/gommon v0.2.8
# 此处省略很多行
...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.3.10-dev
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323

现在查看go.mod 内容:

$ cat go.mod

module hello

go 1.12

require (
    github.com/labstack/echo v3.3.10+incompatible // indirect
    github.com/labstack/gommon v0.2.8 // indirect
    github.com/mattn/go-colorable v0.1.1 // indirect
    github.com/mattn/go-isatty v0.0.7 // indirect
    github.com/valyala/fasttemplate v1.0.0 // indirect
    golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a // indirect
)

go module 安装 package 的原則是先拉最新的 release tag,若无tag则拉最新的commit,详见 Modules官方介绍。 go 会自动生成一个 go.sum 文件来记录 dependency tree:

$ cat go.sum
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
... 省略很多行
  1. 再次执行脚本 go run server.go 发现跳过了检查并安装依赖的步骤。
  2. 可以使用命令 go list -m -u all 来检查可以升级的package,使用go get -u need-upgrade-package 升级后会将新的依赖版本更新到go.mod

    • 也可以使用 go get -u 升级所有依赖

go get 升级

  • 运行 go get -u 将会升级到最新的次要版本或者修订版本(x.y.z, z是修订版本号, y是次要版本号)
  • 运行 go get -u=patch 将会升级到最新的修订版本
  • 运行 go get package@version 将会升级到指定的版本号version
  • 运行go get如果有版本的更改,那么go.mod文件也会更改

示例二:改造现有项目(helloword)

项目目录为:

$ tree
.
├── api
│   └── apis.go
└── server.go

1 directory, 2 files

server.go 源码为:

package main

import (
    api "./api"  // 这里使用的是相对路径
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", api.HelloWorld)
    e.Logger.Fatal(e.Start(":1323"))
}

api/apis.go 源码为:

package api

import (
    "net/http"

    "github.com/labstack/echo"
)

func HelloWorld(c echo.Context) error {
    return c.JSON(http.StatusOK, "hello world")
}
  1. 使用 go mod init *** 初始化go.mod
$ go mod init helloworld
go: creating new go.mod: module helloworld
  1. 运行 go run server.go
go: finding github.com/labstack/gommon/color latest
go: finding github.com/labstack/gommon/log latest
go: finding golang.org/x/crypto/acme/autocert latest
go: finding golang.org/x/crypto/acme latest
go: finding golang.org/x/crypto latest
build command-line-arguments: cannot find module for path _/home/gs/helloworld/api

首先还是会查找并下载安装依赖,然后运行脚本 server.go,这里会抛出一个错误:

build command-line-arguments: cannot find module for path _/home/gs/helloworld/api

但是go.mod 已经更新:

$ cat go.mod
module helloworld

go 1.12

require (
        github.com/labstack/echo v3.3.10+incompatible // indirect
        github.com/labstack/gommon v0.2.8 // indirect
        github.com/mattn/go-colorable v0.1.1 // indirect
        github.com/mattn/go-isatty v0.0.7 // indirect
        github.com/valyala/fasttemplate v1.0.0 // indirect
        golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a // indirect
)
那为什么会抛出这个错误呢?

这是因为 server.go 中使用 internal package 的方法跟以前已经不同了,由于 go.mod会扫描同工作目录下所有 package 并且变更引入方法,必须将 helloworld当成路径的前缀,也就是需要写成 import helloworld/api,以往 GOPATH/dep 模式允许的 import ./api 已经失效,详情可以查看这个 issue

  1. 更新旧的package import 方式

所以server.go 需要改写成:

package main

import (
    api "helloworld/api"  // 这是更新后的引入方法
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", api.HelloWorld)
    e.Logger.Fatal(e.Start(":1323"))
}
一个小坑:开始在golang1.11 下使用go mod 遇到过 go build github.com/valyala/fasttemplate: module requires go 1.12这种错误,遇到类似这种需要升级到1.12 的问题,直接升级golang1.12 就好了。幸亏是在1.12 发布后才尝试的go mod 🤷‍♂️
  1. 到这里就和新创建一个项目没什么区别了

使用replace替换无法直接获取的package

由于某些已知的原因,并不是所有的package都能成功下载,比如:golang.org下的包。

modules 可以通过在 go.mod 文件中使用 replace 指令替换成github上对应的库,比如:

replace (
    golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a => github.com/golang/crypto v0.0.0-20190313024323-a1f597ede03a
)

或者

replace golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a => github.com/golang/crypto v0.0.0-20190313024323-a1f597ede03a

参考链接

References

[1] Modules官方介绍: https://github.com/golang/go/...
[2] issue: https://github.com/golang/go/...
[3] 这种错误: https://github.com/golang/go/...
[4] Modules官方介绍: https://github.com/golang/go/...
[5] Golang 1.11 新功能介紹 – Modules: https://www.lightblue.asia/go...
[6] What are Go modules and how do I use them?: https://talks.godoc.org/githu...
[7] go mod doesn't work for github.com/gomarkdown/markdown/html : https://github.com/golang/go/...
[8] 再探go modules:使用与细节: https://www.cnblogs.com/apoce...
[9] 初窥Go module: https://tonybai.com/2018/07/1...


最后,感谢女朋友支持和包容,比❤️

也可以在公号输入以下关键字获取历史文章:公号&小程序 | 设计模式 | 并发&协程

查看原文

飘香豆腐 赞了文章 · 2020-11-19

Go mod 使用

go modules 是 golang 1.11 新加的特性。现在1.12 已经发布了,是时候用起来了。Modules官方定义为:

模块是相关Go包的集合。modules是源代码交换和版本控制的单元。 go命令直接支持使用modules,包括记录和解析对其他模块的依赖性。modules替换旧的基于GOPATH的方法来指定在给定构建中使用哪些源文件。

如何使用 Modules ?

  1. 把 golang 升级到 1.11(现在1.12 已经发布了,建议使用1.12)
  2. 设置 GO111MODULE

GO111MODULE

GO111MODULE 有三个值:off, onauto(默认值)

  • GO111MODULE=off,go命令行将不会支持module功能,寻找依赖包的方式将会沿用旧版本那种通过vendor目录或者GOPATH模式来查找。
  • GO111MODULE=on,go命令行会使用modules,而一点也不会去GOPATH目录下查找。
  • GO111MODULE=auto,默认值,go命令行将会根据当前目录来决定是否启用module功能。这种情况下可以分为两种情形:

    • 当前目录在GOPATH/src之外且该目录包含go.mod文件
    • 当前文件在包含go.mod文件的目录下面。
当modules 功能启用时,依赖包的存放位置变更为$GOPATH/pkg,允许同一个package多个版本并存,且多个项目可以共享缓存的 module。

go mod

golang 提供了 go mod命令来管理包。

go mod 有以下命令:

命令说明
downloaddownload modules to local cache(下载依赖包)
editedit go.mod from tools or scripts(编辑go.mod
graphprint module requirement graph (打印模块依赖图)
initinitialize new module in current directory(在当前目录初始化mod)
tidyadd missing and remove unused modules(拉取缺少的模块,移除不用的模块)
vendormake vendored copy of dependencies(将依赖复制到vendor下)
verifyverify dependencies have expected content (验证依赖是否正确)
whyexplain why packages or modules are needed(解释为什么需要依赖)

如何在项目中使用

示例一:创建一个新项目

  1. GOPATH 目录之外新建一个目录,并使用go mod init 初始化生成go.mod 文件
➜  ~ mkdir hello
➜  ~ cd hello
➜  hello go mod init hello
go: creating new go.mod: module hello
➜  hello ls
go.mod
➜  hello cat go.mod
module hello

go 1.12
go.mod文件一旦创建后,它的内容将会被go toolchain全面掌控。go toolchain会在各类命令执行时,比如go get、go build、go mod等修改和维护go.mod文件。

go.mod 提供了module, requirereplaceexclude 四个命令

  • module 语句指定包的名字(路径)
  • require 语句指定的依赖项模块
  • replace 语句可以替换依赖项模块
  • exclude 语句可以忽略依赖项模块
  1. 添加依赖

新建一个 server.go 文件,写入以下代码:

package main

import (
    "net/http"
    
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })
    e.Logger.Fatal(e.Start(":1323"))
}

执行 go run server.go 运行代码会发现 go mod 会自动查找依赖自动下载:

$ go run server.go
go: finding github.com/labstack/echo v3.3.10+incompatible
go: downloading github.com/labstack/echo v3.3.10+incompatible
go: extracting github.com/labstack/echo v3.3.10+incompatible
go: finding github.com/labstack/gommon/color latest
go: finding github.com/labstack/gommon/log latest
go: finding github.com/labstack/gommon v0.2.8
# 此处省略很多行
...

   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v3.3.10-dev
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:1323

现在查看go.mod 内容:

$ cat go.mod

module hello

go 1.12

require (
    github.com/labstack/echo v3.3.10+incompatible // indirect
    github.com/labstack/gommon v0.2.8 // indirect
    github.com/mattn/go-colorable v0.1.1 // indirect
    github.com/mattn/go-isatty v0.0.7 // indirect
    github.com/valyala/fasttemplate v1.0.0 // indirect
    golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a // indirect
)

go module 安装 package 的原則是先拉最新的 release tag,若无tag则拉最新的commit,详见 Modules官方介绍。 go 会自动生成一个 go.sum 文件来记录 dependency tree:

$ cat go.sum
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
... 省略很多行
  1. 再次执行脚本 go run server.go 发现跳过了检查并安装依赖的步骤。
  2. 可以使用命令 go list -m -u all 来检查可以升级的package,使用go get -u need-upgrade-package 升级后会将新的依赖版本更新到go.mod

    • 也可以使用 go get -u 升级所有依赖

go get 升级

  • 运行 go get -u 将会升级到最新的次要版本或者修订版本(x.y.z, z是修订版本号, y是次要版本号)
  • 运行 go get -u=patch 将会升级到最新的修订版本
  • 运行 go get package@version 将会升级到指定的版本号version
  • 运行go get如果有版本的更改,那么go.mod文件也会更改

示例二:改造现有项目(helloword)

项目目录为:

$ tree
.
├── api
│   └── apis.go
└── server.go

1 directory, 2 files

server.go 源码为:

package main

import (
    api "./api"  // 这里使用的是相对路径
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", api.HelloWorld)
    e.Logger.Fatal(e.Start(":1323"))
}

api/apis.go 源码为:

package api

import (
    "net/http"

    "github.com/labstack/echo"
)

func HelloWorld(c echo.Context) error {
    return c.JSON(http.StatusOK, "hello world")
}
  1. 使用 go mod init *** 初始化go.mod
$ go mod init helloworld
go: creating new go.mod: module helloworld
  1. 运行 go run server.go
go: finding github.com/labstack/gommon/color latest
go: finding github.com/labstack/gommon/log latest
go: finding golang.org/x/crypto/acme/autocert latest
go: finding golang.org/x/crypto/acme latest
go: finding golang.org/x/crypto latest
build command-line-arguments: cannot find module for path _/home/gs/helloworld/api

首先还是会查找并下载安装依赖,然后运行脚本 server.go,这里会抛出一个错误:

build command-line-arguments: cannot find module for path _/home/gs/helloworld/api

但是go.mod 已经更新:

$ cat go.mod
module helloworld

go 1.12

require (
        github.com/labstack/echo v3.3.10+incompatible // indirect
        github.com/labstack/gommon v0.2.8 // indirect
        github.com/mattn/go-colorable v0.1.1 // indirect
        github.com/mattn/go-isatty v0.0.7 // indirect
        github.com/valyala/fasttemplate v1.0.0 // indirect
        golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a // indirect
)
那为什么会抛出这个错误呢?

这是因为 server.go 中使用 internal package 的方法跟以前已经不同了,由于 go.mod会扫描同工作目录下所有 package 并且变更引入方法,必须将 helloworld当成路径的前缀,也就是需要写成 import helloworld/api,以往 GOPATH/dep 模式允许的 import ./api 已经失效,详情可以查看这个 issue

  1. 更新旧的package import 方式

所以server.go 需要改写成:

package main

import (
    api "helloworld/api"  // 这是更新后的引入方法
    "github.com/labstack/echo"
)

func main() {
    e := echo.New()
    e.GET("/", api.HelloWorld)
    e.Logger.Fatal(e.Start(":1323"))
}
一个小坑:开始在golang1.11 下使用go mod 遇到过 go build github.com/valyala/fasttemplate: module requires go 1.12这种错误,遇到类似这种需要升级到1.12 的问题,直接升级golang1.12 就好了。幸亏是在1.12 发布后才尝试的go mod 🤷‍♂️
  1. 到这里就和新创建一个项目没什么区别了

使用replace替换无法直接获取的package

由于某些已知的原因,并不是所有的package都能成功下载,比如:golang.org下的包。

modules 可以通过在 go.mod 文件中使用 replace 指令替换成github上对应的库,比如:

replace (
    golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a => github.com/golang/crypto v0.0.0-20190313024323-a1f597ede03a
)

或者

replace golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a => github.com/golang/crypto v0.0.0-20190313024323-a1f597ede03a

参考链接

References

[1] Modules官方介绍: https://github.com/golang/go/...
[2] issue: https://github.com/golang/go/...
[3] 这种错误: https://github.com/golang/go/...
[4] Modules官方介绍: https://github.com/golang/go/...
[5] Golang 1.11 新功能介紹 – Modules: https://www.lightblue.asia/go...
[6] What are Go modules and how do I use them?: https://talks.godoc.org/githu...
[7] go mod doesn't work for github.com/gomarkdown/markdown/html : https://github.com/golang/go/...
[8] 再探go modules:使用与细节: https://www.cnblogs.com/apoce...
[9] 初窥Go module: https://tonybai.com/2018/07/1...


最后,感谢女朋友支持和包容,比❤️

也可以在公号输入以下关键字获取历史文章:公号&小程序 | 设计模式 | 并发&协程

查看原文

赞 32 收藏 22 评论 1

飘香豆腐 关注了用户 · 2020-11-12

花裤衩 @panjiachen

show me the code

关注 2597

飘香豆腐 关注了专栏 · 2020-11-12

手摸手系列

手摸手带你学习前端系列

关注 1770

飘香豆腐 赞了文章 · 2020-11-10

手摸手,带你用vue撸后台 系列三(实战篇)

完整项目地址:vue-element-admin
系类文章一:手摸手,带你用vue撸后台 系列一(基础篇)
系类文章二:手摸手,带你用vue撸后台 系列二(登录权限篇)
系类文章三:手摸手,带你用vue撸后台 系列三(实战篇)
系类文章四:手摸手,带你用vue撸后台 系列四(vueAdmin 一个极简的后台基础模板)
系类文章:手摸手,带你优雅的使用 icon
系类文章:手摸手,带你封装一个vue component

前言

在前面两篇文章中已经把基础工作环境构建完成,也已经把后台核心的登录和权限问题完成了,现在手摸手,一起进入实操。

Element

去年十月份开始用 vue 做管理后台的时候毫不犹豫的就选择了 element-ui,那时候 vue2.0 刚发布也没多久,市面上也没有很多其它的 vue2.0 的 ui 框架可供选择。虽然 element-ui 也有很多的不足,前期的bug也不少,但我还是选择了它,简单说一下我选择element-ui的原因吧:

  • 有大厂背书 : 虽然核心开发只有两三个人,但至少不用担心哪天就不维护,带着小姨子跑路了
  • 持续迭代 : element-ui发版至今release了四十多个版本,之前平均都是一周一个小版本更新(是不是不小心暴露了它bug多的问题/(ㄒoㄒ)/~~)(ps: 至2017.12.4 已经迭代了74个版本,还保持着较高更新频率)。
  • 生态圈优异,社区活跃 :其 contributors已经有250多人(前期我有饶有兴致的贡献过几个pr,参与过七八十个issue),社区里也有很多基于element-ui 的拓展组件,也有很多相关的 qq 讨论群或者 gitter
  • 社区的认可:目前Element已经是vue相关最多star的开源项目了,体现出了社区对其的认可。

说了这么多优点,作为一个资深element-ui用户还是有些要抱怨的~和react老大哥 Ant Design 相比还是有一定的差距的,不管是组件的丰富性,参数的可配性还是文档的完整性,亦或是UI的交互和美观度。不过 ant 也是经过了近9k次commit的不断打磨,才有了今天。我也相信 element-ui也会越来越好的。

这里还有一些其它的框架(只讨论pc端的框架)大家可以自行选择:

  • ivew 一国人个人写的框架,美观度和交互性都不错,有种介于Element和Ant之间的感觉,之前和element团队小小的撕了一下,有兴趣的自己去围观吧,框架还是很不做的,一个人能做出这样,也是很不容易的。作者公开信件
  • vue-admin 也是一个不错的选择,代码写的和不错,官方也出了一个admin的架子,也很值得借鉴
  • vue-material 一个material design vue框架库
  • vuetify 又是一个material design vue框架库
  • Keen-UI 又又是一个material design vue框架库
  • CoreUI-Free-Bootstrap-Admin-Template 和以前的Bootstrap一样,搭好了一个完整的架子,大家可以进行二次拓展,它有vue,react,angular多个版本
  • Framework7-Vue 个人感觉这是本人体验到现在移动端体验最好的框架。不过Framework7-Vue感觉还不是很完善,还需要观望一段时间。而且它有自己的路由规则,所以不能使用 vue-router,这点还是很不方便的。

简单列举了一些主流的框架,不得不感慨现在vue的生态圈真是太繁荣了,上述框架楼主并没有深入使用过,不好发表太多建议,大家自行甄别适合自己业务的框架吧。


这里开始我们会开始介绍一些结合Element的开发经验。

基于Element的动态换肤

有些产品就是这么残忍,能完成需求就不错了,还要让我们做动态换肤。Element官网上也提供了自定义主题的方案
同时也提供了一个在线自定义主题的demo

是不是很酷,作者也说明了实现的方案 地址,大概思路:

  1. 先把默认主题文件中涉及到颜色的 CSS 值替换成关键词
  2. 根据用户选择的主题色生成一系列对应的颜色值
  3. 把关键词再换回刚刚生成的相应的颜色值
  4. 直接在页面上加 style 标签,把生成的样式填进去

我看完觉得真的还是有点复杂的。有没有简单的方案呢?
让我们思考一下,让我们自己写动态换肤该怎么写呢?最常见的方法就是写两套主题,一套叫day theme ,一套叫night themenight theme主题 都在一个.night-theme的命名空间下,我们动态的在bodyadd .night-themeremove .night-theme。这就是最简单的动态换肤。所以我们也能不能顺着这个思路,基于 element-ui 实现动态换肤呢?

首先我们下载官方通过的 Theme generator ,一个专门用来生成Element主题的工具。按照文档,我们生成了需要的主题。


之后就是我们要做的事情了,将这个主题的每个元素外面包裹一个class 来做命名空间。
我们这里用到了gulp-css-wrap这个神器,轻轻松松就完成了我们想要的结果

var path = require('path')
var gulp = require('gulp')
var cleanCSS = require('gulp-clean-css');
var cssWrap = require('gulp-css-wrap');

var customThemeName='.custom-theme'

gulp.task('css-wrap', function() {
  return gulp.src( path.resolve('./theme/index.css'))
    .pipe(cssWrap({selector:customThemeName}))
    .pipe(cleanCSS())
    .pipe(gulp.dest('dist'));
});

gulp.task('move-font', function() {
  return gulp.src(['./theme/fonts/**']).pipe(gulp.dest('dist/fonts'));
});

gulp.task('default',['css-wrap','move-font']);

这样就得到了一个以.custom-theme为命名空间的自定义主题了,之后我们在项目中引入主题

//main.js
import 'assets/custom-theme/index.css'

我们在换肤的地方toggleClass(document.body, 'custom-theme')一直toggle body 的 class就可以了。我们就简单实现了动态换肤效果。


不过这种模式实现换肤也是有一个弊端的,它等于把这两个主题都打包在了项目里,如果你的项目主题需要七八种,这种模式就不适合了。我们就需要动态的加载css,下面就是最简单的动态添加css的例子,当然你可以封装一下,增加成功或者失败回调,判断是否加载过改资源等等就不展开了。

var head = document.getElementsByTagName('HEAD').item(0);
var style = document.createElement('link');
style.href = 'style.css';
style.rel = 'stylesheet';
style.type = 'text/css';
head.appendChild(style);

更新(2017.12)

element-ui 官方更新了2.0版本,同时也提供了一个新的换肤思路。
文档


侧边栏

这里又有谈一下导航栏的问题,本项目里的侧边栏是根据 router.js 配置的路由并且根据权限动态生成的,这样就省去了写一遍路由还要手动再写一次侧边栏这种麻烦事,但也遇到了一个问题,路由可能会有多层嵌套,很多人反馈自己的侧边栏会有三级,甚至还有五级的。所以重构了一下侧边栏,使用了递归组件,这样不管你多少级,都能愉快的显示了。代码

侧边栏高亮问题: 很多人在群里问为什么自己的侧边栏不能跟着自己的路由高亮,其实很简单,element-ui官方已经给了default-active所以我们只要

:default-active="$route.path"

default-active一直指向当前路由就可以了,就是这么简单。

点击侧边栏 刷新当前路由

在用 spa(单页面开发) 这种开发模式之前,大部分都是多页面后台,用户每次点击侧边栏都会重新请求这个页面,用户渐渐养成了点击侧边栏当前路由来刷新页面的习惯。但现在 spa 就不一样了,用户点击当前高亮的路由并不会刷新view,因为vue-router会拦截你的路由,它判断你的url并没有任何变化,所以它不会触发任何钩子或者是view的变化。issue地址,社区也对该问题展开了激烈讨论。

尤大本来也说要增加一个方法来强刷view,但后来他又改变了心意/(ㄒoㄒ)/~~。但需要就摆在这里,我们该怎么办呢?他说了不改变current URL 就不会触发任何东西,那我可不可以强行触发东西你?上有政策, 下有对策我们变着花来hack。方法也很简单,通过不断改变urlquery来触发view的变化。我们监听侧边栏每个link 的 click事件,每次点击都给router push 一个不一样的query 来确保会重新刷新view。

clickLink(path) {
  this.$router.push({
    path,
    query: {
      t: +new Date() //保证每次点击路由的query项都是不一样的,确保会重新刷新view
    }
  })
}

但这也有一个弊端就是 url 后面有一个很难看的 query 后缀如 xxx.com/article/list?t=1496832345025,但我司用户们表示能接受。。。只能暂时这样hack了,不知道大家有没有更好的方法,学习学习。


Table

经过好几个版本的迭代,element-ui 的table组件已经能满足大部分业务需求了。不过rowSpan colSpan表格行/列合并现在并不是支持(element-ui2.0版本之后开始支持)。官方对此功能的更新情况可以关注这个issue

这里我着重讲一下table表格几个常用的业务形态。

Table 拖拽排序


这里主要是基于Sortable

import Sortable from 'sortablejs'
let el = document.querySelectorAll('.el-table__body-wrapper > table > tbody')[0]
let sortable = Sortable.create(el)

在table mounted之后申明Sortable.create(el) table的每行tr就可以随意拖拽了,麻烦的目前我们的排序都是基于dom的,我们的数据层list并没有随之改变。所以我们就要手动的来管理我们的列表。

this.sortable = Sortable.create(el, {
  onEnd: evt => { //监听end事件 手动维护列表
    const tempIndex = this.newList.splice(evt.oldIndex, 1)[0];
    this.newList.splice(evt.newIndex, 0, tempIndex);
  }
});

这样我们就简单的完成了 table 拖拽排序。这里如果不是基于 dom 的排序推荐使用Vue.Draggable完整代码


Table 内联编辑

table内联编辑也是一个常见的需求。


其实也很简单,当我们拿到 list 数据之后先洗一下数据,每一条数据里面插入一个edit[ true or false ]判断符,来表示当前行是否处于编辑状态。之后就是通过v-show动态切换不同的相应view就可以了。完整代码

<el-table-column min-width="300px" label="标题">
  <template scope="scope">
    <el-input v-show="scope.row.edit" size="small" v-model="scope.row.title"></el-input>
    <span v-show="!scope.row.edit">{{ scope.row.title }}</span>
  </template>
</el-table-column>
<el-table-column align="center" label="编辑" width="120">
  <template scope="scope">
    <el-button v-show='!scope.row.edit' type="primary" @click='scope.row.edit=true' size="small" icon="edit">编辑</el-button>
    <el-button v-show='scope.row.edit' type="success" @click='scope.row.edit=false' size="small" icon="check">完成</el-button>
  </template>
</el-table-column>

Table 常见坑

通过dialog来编辑,新建,删除table的元素这种业务场景相对于前面说的两种更加的常见。而且也有不少的小坑。
首先我们要明确一个点 vue 是一个MVVM框架,我们传统写代码是命令式编程,拿到table这个dom之后就是命令式对dom增删改。而我们现在用声明式编程,只用关注data的变化就好了,所以我们这里的增删改都是基于list这个数组来的。这里我们还要明确一点vue 列表渲染注意事项

由于 JavaScript 的限制, Vue 不能检测以下变动的数组:
* 当你利用索引直接设置一个项时,例如: vm.items[indexOfItem] = newValue

所以我们想改变table中第一条数据的值,通过this.list[0]=newValue这样是不会生效的。

解决方案:
// Array.prototype.splice`
example1.items.splice(indexOfItem, 1, newValue)

所以我们可以通过

//添加数据
this.list.unshift(this.temp);

//删除数据 
const index = this.list.indexOf(row); //找到要删除数据在list中的位置
this.list.splice(index, 1); //通过splice 删除数据

//修改数据
const index = this.list.indexOf(row); //找到修改的数据在list中的位置
this.list.splice(index, 1,this.updatedData); //通过splice 替换数据 触发视图更新

这样我们就完成了对table的增删改操作,列表view也自动响应发生了变化。这里在修改数据的时候还有一个小坑需要主要
当我们拿到需要修改行的数据时候不能直接将它直接赋值给dialog,不然会发生下面的问题。


如上图所示,我们在dialog里面改变状态的时候,遮罩下面的table里面该行的状态也在那里跟着一只变化着。原因想必大家都猜到了。赋值的数据是一个objec引用类型共享一个内存区域的。所以我们就不能直接连等复制,需要重新指向一个新的引用,方案如下:

//赋值对象是一个obj
this.objData=Object.assign({}, row) //这样就不会共用同一个对象

//数组我们也有一个巧妙的防范
newArray = oldArray.slice(); //slice会clone返回一个新数组

Tabs

tab在后台项目中也比较常用的。假设我们有四个tab选项,每个tab都会向后端请求数据,但我们希望一开始只会请求当前的tab数据,而且tab来回切换的时候不会重复请求,只会实例化一次。首先我们想到的就是用v-if 这样的确能做到一开始不会挂载后面的tab,但有一个问题,每次点击这个tab组件都会重新挂载一次,这是我们不想看到的,这时候我们就可以用到<keep-alive>了。

keep-alive 包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。 它是一个抽象组件:它自身不会渲染一个 DOM 元素,也不会出现在父组件链中。

所以我们就可以这样写tabs了

<el-tabs v-model="activeTab">
  <el-tab-pane label="简介及公告" name="announcement">
    <announcement />
  </el-tab-pane>
  <el-tab-pane label="资讯" name="information">
    <keep-alive>
      <information v-if="activeTab=='information'" />
    </keep-alive>
  </el-tab-pane>
  <el-tab-pane label="直播流配置" name="stream">
    <keep-alive>
      <stream v-if="activeTab=='stream'" />
    </keep-alive>
  </el-tab-pane>
</el-tabs>

Select 选择器

Select 选择器直接使用没有什么太多问题,但很多时候我们需要通过Select来回显一些数据,当我们<el-select v-model="objValue"> select 绑定一个obj value回显就会很蛋疼了,它要求必须保持同一个引用issue。这就意味着,我们回显数据的时候想先要找到该数据在arr中的位置,再回塞:demo。这还不是在远程搜索的情况下,如果是远程搜索的情况还要当疼。
这里推荐一下vue-multiselect 它能完美的解决前面Element select的问题。目前也是vue component 中比较好用的一个,ui也非常的好看,建议大家可以尝试性用一下,真的非常的不错。


Upload 上传

Upload本身没什么好说的,文档写的蛮清楚了。这里主要说一下怎么将Upload组件和七牛直传结合在一起。

这里我们选择api直传的方式,就是我们首先要通过后端(go,node,php都可以)文档生成七牛上传必要的token(上传凭证)和key(资源的最终名称)。
所以现在只要想办法讲token和key塞进post请求里面就可以了,好在官方也提供了这个方法。


。但怎么才能先异步的拿到token再将它塞入请求里呢?


这时候我们又发现了before-upload 这个钩子还支持promise简直合我们的心意。
但我们写着写着怎样才能动态的改变之前的dataObj呢?通过看源码发现我们可以_self._data这样子拿到我们想要的数据。线上代码

<template>
  <el-upload
      action="https://upload.qbox.me"
      :data="dataObj"
      drag
      :multiple="true"
      :before-upload="beforeUpload">
    <i class="el-icon-upload"></i>
    <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
  </el-upload>
</template>
<script>
    import { getToken } from 'api/qiniu'; // 获取七牛token 后端通过Access Key,Secret Key,bucket等生成token
    // 七牛官方sdk https://developer.qiniu.com/sdk#official-sdk
    export default{
      data() {
        return {
          dataObj: { token: '', key: '' },
          image_uri: [],
          fileList: []
        }
      },
      methods: {
        beforeUpload() {
          const _self = this;
          return new Promise((resolve, reject) => {
            getToken().then(response => {
              const key = response.data.qiniu_key;
              const token = response.data.qiniu_token;
              _self._data.dataObj.token = token;
              _self._data.dataObj.key = key;
              resolve(true);
            }).catch(err => {
              console.log(err)
              reject(false)
            });
          });
        }
      }
    }
</script>

jsx

在使用Element的时候,官方提供了很多可以自己写render function的地方,但由于Element内部都是用jsx 写render function的,所以demo也都是jsx,但很多人自己项目中其实是没有安装的,导致报错。但说真的用createElement裸写render 函数还是有些蛋疼。我们要用jsx,首先要安装 babel-plugin-transform-vue-jsx 安装方法如下:

npm install\
  babel-plugin-syntax-jsx\
  babel-plugin-transform-vue-jsx\
  babel-helper-vue-jsx-merge-props\
  babel-preset-es2015\
  --save-dev
  

.babelrc:文件

{
  "presets": ["es2015"],
  "plugins": ["transform-vue-jsx"]
}

这样我们就可以愉快的使用 jsx 写render function了。


element 常见问题

click事件不触发问题:一直有人在群里问<el-input @click="handlenClick">Click Me</el-input>怎么不触发click事件,虽然element文档还有完善的空间但这种问题大家还真要自己好好认真看一下官方的FAQ了。

官方说明了所有的原生事件必须添加 .native 修饰符。

修改element样式问题: 用ui组件总免不了需要对它做一些个性化定制的需求,所以我们就要覆盖element的一些样式。
首先我们要了解一下vue scoped是什么,很多人非常喜欢用scoped,妈妈再也不用担心样式冲突问题了,其实scoped也没有很神秘的,它就是基于PostCss的,加了一个作用局的概念。

//编译前
.example {
  color: red;
}
//编译后
.example[_v-f3f3eg9] {
  color: red;
}

它和我们传统的命名空间的方法避免css冲突没有什么本质性的区别。
现在我们来说说怎么覆盖element-ui样式。由于element-ui的样式我们是在全局引入的,所以你想在某个view里面覆盖它的样式就不能加scoped,但你又想只覆盖这个页面的element样式,你就可在它的父级加一个class,以用命名空间来解决问题。

.aritle-page{ //你的命名空间
    .el-tag { //element-ui 元素
      margin-right: 0px;
    }
}

建议向楼主一样专门建一个scss文件里专门自定义element-ui的各种样式。线上代码

其它关于element相关的东西真的没有什么好说的了,人家文档和源码就放在那里,有问题就去看文档,再去issue里找找,再去看看源码,大部分问题都能解决了。给一个诀窍其实大部分诡异的问题都可以通过加一个key或者
Vue.nextTick来解决。。


富文本

管理后台富文本也是一个非常重要的功能,楼主在这里也踩了不少的坑。楼主在项目里最终选择了 tinymce

这里在简述一下推荐使用tinymce的原因:tinymce 是一家老牌做富文本的公司(这里也推荐 ckeditor,也是一家一直做富文本的公司,新版本很不错),它的产品经受了市场的认可,不管是文档还是配置的自由度都很好。在使用富文本的时候有一点也很关键就是复制格式化,之前在用一款韩国人做的富文本summernote被它的格式化坑的死去活来,但 tinymce 的去格式化相当的好,它还有一个增值项目就是powerpaste,那是无比的强大,支持从word里面复制各种东西,都不会有问题。富文本还有一点也很关键,就是拓展性。楼主用tinymce写了好几个插件,学习成本和容易度都不错,很方便拓展。最后一点就是文档很完善,基本你想得到的配置项,它都有。tinymce也支持按需加载,你可以通过它官方的build页定制自己需要的plugins。
我再来分析一下市面上其它的一些富文本:

  • summernote 先来说一个我绝对不推荐的富文本。这是一个韩国人开源的富文本(当然不推荐的理由不是因为这个),它对很多富文本业界公认的默认行为理解是反起到而行的,而且只为用了一个dialog的功能,引入了boostrap,一堆人抗议就是不改。格式化也是差劲。。反正不要用!不要用!不要用!
  • ckeditor ckeditor也是一家老牌做富文本的公司,楼主旧版后台用的就是这个,今年也出了5.0版本,ui也变美观了不少,相当的不错,而且它号称是插件最丰富的富文本了。推荐大家也可以试用一下。
  • quill 也是一个非常火的富文本,长相很不错。基于它写插件也很简单,api设计也很简单。楼主不选择它的原因是它对图片的各种操作不友善,而且很难改。如果对图片没什么操作的用户,推荐使用。
  • medium-editor 大名鼎鼎的medium的富文本(非官方出品),但完成度还是不很不错,拓展性也不错。不过我觉得大部分用户还是会不习惯medium这种写作方式的。
  • Squire 一个比较轻量的富文本,压缩完才11.5kb,相对于其它的富文本来说是非常的小了,推荐功能不复杂的建议使用。
  • wangEditor 一个国人写的富文本,用过感觉还是不错的。不过毕竟是个人的,不像专门公司做富文本的,配置型和丰富性不足。前端几大禁忌就有富文本 为什么都说富文本编辑器是天坑?,不过个人能做成这样子很不容易了。
  • 百度UEditor 没有深入使用过,只在一个angular1X的项目简单用过,不过说着的ui真的不好看,不符合当今审美了,官方也已经很久没跟新过了。

楼主列举了很多富文本但并没有列举任何 vue 相关的富文本,主要是因为富文本真的比想象中复杂,在前面的文章里也说过了,其实用 vue 封装组件很方便的,没必要去用人家封装的东西什么vue-quill vue-editor这种都只是简单包了一层,没什么难度的。还不如自己来封装,灵活性可控性更强一点。还有一点基于 vue 真没什么好的富文本,不像 react 有 facebook 出的 draft-js,ory 出的 editor,这种大厂出的产品。

当然你也可以选择一些付费的富文本编辑器,作者自己公司里面有一个项目就使用了 froala-editor 这款编辑器。不管是美观和易用性都是不错的,公司买的是专业版,一年也就 $349 ,价格也是很合理的,但其实省去的程序员开发陈本可能远不止这个价钱。

Tinymce

这里来简单讲一下在自己项目中使用 Tinymce 的方法。

由于目前使用 npm 安装 Tinymce 方法比较负责复杂而且还有一些问题(日后可能会采用该模式)。

目前采用全局引用的方式。代码地址:static/tinymce static目录下的文件不会被打包, 在 index.html 中引入。

使用
由于富文本不适合双向数据流,所以只会 watch 传入富文本的内容一次变化,只会就不会再监听了,如果之后还有改变富文本内容的需求。
可以通过 this.refs.xxx.setContent() 来设置

源码也很简单,有任何别的需求都可以在 @/components/Tinymce/index.vue 中自行修改。


Markdown

markdown 我们这里选用了 simplemde-markdown-editor ,简单的用vue封装了一下地址,如果需求方能接受 markdown 就一定要用 markdown,坑真心会比富文本少很多。这里我们用markdown做了编辑器,还需要一个能解析的的东西。可以你传给后端让后端帮你转化,也可以前端自己来,这里推荐一个转化库showdown。使用方法:

import('showdown').then(showdown => { //用了 Dynamic import
  const converter = new showdown.Converter();//初始化
  this.html = converter.makeHtml(this.content)//转化
})

用法也很简单两行代码就完成了markdown to html,当然它还有很多个性画的配置,大家有需求自行研究吧。


导出excel

这里先明确一点,如果你的业务需求对导出文件的格式没有什么要求,不建议导出成xlsx格式的,直接导出成csv的就好了,真的会简单很多。创建一个a标签,写上data:text/csv;charset=utf-8头,再把数据塞进去,encodeURI(csvContent)一下就好了,详情就不展开了,大家可以借鉴这个stackoverflow回答
我们重点说一下转xlsx,我们这里用到了js-xlsx,一个功能很强大excel处理库,只是下载各种格式excel,还支持读取excel,但上手难度也非常大,相当的复杂,其中涉及不少二进制相关的东西。不过好在官方给了我们一个demo例子,我们写不来还抄不来么,于是我们就借鉴官方的例子来改造了一下,具体原理就不详细说了,真的很复杂。。。
重点是我们怎么使用!首先我们封装一个Export2Excel.js
它又依赖三个库

require('script-loader!file-saver'); //保存文件用
require('script-loader!vendor/Blob'); //转二进制用
require('script-loader!xlsx/dist/xlsx.core.min'); //xlsx核心

由于这几个文件不支持import引入,所以我们需要`script-loader`来将他们挂载到全局环境下。

它暴露了两个接口export_table_to_excelexport_json_to_excel,我们常用export_json_to_excel因为更加的可控一点,我们可以自由的洗数据。

handleDownload() {
  require.ensure([], () => { // 用 webpack Code Splitting xlsl还是很大的
    const { export_json_to_excel } = require('vendor/Export2Excel');
    const tHeader = ['序号', '文章标题', '作者', '阅读数', '发布时间']; // excel 表格头
    const filterVal = ['id', 'title', 'author', 'pageviews', 'display_time'];
    const list = this.list;
    const data = this.formatJson(filterVal, list); // 自行洗数据 按序排序的一个array数组
    export_json_to_excel(tHeader, data, '列表excel');
  })
},
formatJson(filterVal, jsonData) {
  return jsonData.map(v => filterVal.map(j => v[j]))
}

完整显示线上代码


ECharts

管理后台图表也是常见得需求。这里图表就只推荐ECharts,功能齐全,社区demo也丰富gallery。我还是那个观点,大部分插件建议大家还是自己用vue来包装就好了,真的很简单。ECharts支持webpack引入,图省事可以将ECharts整个引入var echarts = require('echarts');不过ECharts还是不小的,我们大部分情况只是用到很少一部分功能,我平时习惯于按需引入的。

// 引入 ECharts 主模块
var echarts = require('echarts/lib/echarts');
// 引入柱状图
require('echarts/lib/chart/bar');
// 引入提示框和标题组件
require('echarts/lib/component/tooltip');
require('echarts/lib/component/title');

webpack中使用ECharts文档
ECharts按需引入模块文档
接下来我们就要在vue中声明初始化ECharts了。因为ECharts初始化必须绑定dom,所以我们只能在vue的mounted生命周期里初始化。

mounted() {
  this.initCharts();
},
methods: {
  this.initCharts() {
    this.chart = echarts.init(this.$el);
    this.setOptions();
  },
  setOptions() {
    this.chart.setOption({
      title: {
        text: 'ECharts 入门示例'
      },
      tooltip: {},
      xAxis: {
        data: ["衬衫", "羊毛衫", "雪纺衫", "裤子", "高跟鞋", "袜子"]
      },
      yAxis: {},
      series: [{
        name: '销量',
        type: 'bar',
        data: [5, 20, 36, 10, 10, 20]
      }]
    })
  }
}

就这样简单,ECharts就配置完成了,这时候你想说我的data是远程获取的,或者说我动态改变ECharts的配置该怎么办呢?我们可以通过watch来触发setOptions方法

//第一种 watch options变化 利用vue的深度 watcher,options一有变化就重新setOption
watch: {
  options: {
    handler(options) {
      this.chart.setOption(this.options)
    },
    deep: true
  },
}
//第二种 只watch 数据的变化 只有数据变化时触发ECharts
watch: {
  seriesData(val) {
    this.setOptions({series:val})
  }
}

其实都差不多,还是要结合自己业务来封装。后面就和平时使用ECharts没有什么区别了。题外话ECharts的可配置项真心多,大家使用的时候可能要花一点时间了解它的api的。知乎有个问题:百度还有什么比较良心的产品?答案:ECharts,可见ECharts的强大与好用。


相同component 不同参数

创建与编辑
其实后台创建与编辑功能是最常见的了,它区别去前台项目多了改的需求,但大部分创建页面与编辑页面字段和ui几乎是一样的,所以我们准备公用一个component来对应不同的页面。有两种常见的方法,来区别创建与编辑。

  1. 通过路由path的方式

这种方式最简单暴力,我自己的项目中使用这种方式,通过约定路径中出现'edit'就判断为编辑模式。比较省力和方便,不过这是要在大家写路径的时候都按照规范来写的前提下。

  1. 通过meta来区分

比较推荐这种方式来区分。

computed: {
  isEdit() {
    return this.$route.meta.isEdit // 根据meta判断
    // return this.$route.path.indexOf('edit') !== -1 // 根据路由判断
  }
},
created() {
  if (this.isEdit) { 
    this.fetchData();
  }
},

就这样简单的实现了多路由复用了一个component,其实不只是创建和编辑可以这样用,如两个列表的一模一样,只是一个是内部文章另一个是调取外部文章都能复用组件,通过meta的方式来判断调取不同的接口。


占坑

常规占坑,这里是手摸手,带你用vue撸后台系列。
完整项目地址:vue-element-admin
系类文章一:手摸手,带你用vue撸后台 系列一(基础篇)
系类文章二:手摸手,带你用vue撸后台 系列二(登录权限篇)
系类文章三:手摸手,带你用vue撸后台 系列三(实战篇)
系类文章四:手摸手,带你用vue撸后台 系列四(vueAdmin 一个极简的后台基础模板)
系类文章:手摸手,带你优雅的使用 icon
系类文章:手摸手,带你封装一个vue component
楼主个人免费圈子

查看原文

赞 196 收藏 347 评论 53

飘香豆腐 发布了文章 · 2020-10-23

发布订阅模式和观察者模式

发布/订阅模式

  • 订阅者
  • 发布者
  • 事件中心
我们假定,存在一个“事件中心”,某个任务执行完成,就向事件中心“发布”(publish)一个事件,其他任务可以向事件中心“订阅”(subscribe)这个事件,从而知道什么时候自己可以执行。这就叫做“发布/订阅模式”(publish-subscribe pattern)
// vue 实现的中央事件总线

let vm = new Vue()

// { 'dataChange': [fn1, fn2], 'handleData: [fn]' }
// 注册事件(订阅消息)
vm.$on('dataChange', () => {
    console.log('dataChange1')
})

vm.$on('dataChange', () => {
    console.log('dataChange2')
})

// 触发事件(发布消息)
vm.$emit('dataChange')

下面实现一个发布订阅:

// 手写实现发布订阅

// 事件触发器
class EventEmitter {
    constructor () { 
        // { 'click': [fn1, fn2], 'change: [fn]' }
        // 用一个对象来储存事件订阅信息,同一个事件可被订阅多次,故事件的回调函数用数组储存
        this.subs = Object.create(null)
    }
    
    // 注册事件
    $on (eventType, handler) {
        // 需要找到储存事件订阅信息对象里是否有当前事件,有则获取已有数组,否则赋值为空数组
        this.subs[eventType] = this.subs[eventType] || []   
        // 新注册事件处理函数
        this.subs[eventType].push(handler)
    }
    
    // 触发事件
    $emit (enventType) {
        // 先判断是否有当前事件,有则执行,没有则不作处理
        if(this.subs[eventType]){
            // 遍历数组调用事件函数
            this.subs[eventType].forEach(handler => {
                handler()       
            })
        }
    }
}

const em = new EventEmitter()

em.$on('click', () => {
    console.log('click1')
})
em.$on('click', () => {
    console.log('click2')
})

em.$emit('click')

观察者模式

  • 观察者(订阅者)-- Watcher

    • update(): 当事件发生时,具体要做的事情
  • 目标(发布者)-- Dep

    • subs 数组: 储存所有观察者
    • addSub(): 添加观察者
    • notify(): 当事件发生时,调用所有观察者的 update() 方法
  • 没有事件中心
关于为什么目标(发布者)用 Dep 表示,是因为 Dep 是 dependence(依赖)的缩写。因为 Watcher 观察者(订阅者)需要依赖 Dep 才能了解数据的变化,没有 Dep,Watcher 根本不可能知道数据发生了变化,当有数据变化发生时,Dep 会通知 Watcher。
// 发布者-目标
class Dep {
    constructor () {
        // 记录所有的订阅者
        this.subs = []
    }
    
    // 添加订阅者
    addSub (sub) {
        // 在添加之前,要确保订阅者存在且具有update方法
        if (sub && sub.update) {
            this.subs.push(sub)
        }
    }
    
    // 发布通知
    notify () {
        // 找到所有的订阅者并调用它们的update方法
        this.subs.forEach(sub => {
            sub.updatre()
        })
    }
}

// 订阅者-观察者
class Watcher {
    update () {
        console.log('update')
    }
}

let dep = new Dep()
let watcher = new Watcher()

dep.addSub(watcher)

dep.notify()

总结

  • 观察者模式 是由具体目标调度,比如当事件触发,Dep 就会去调用观察者的方法,所以观察者模式的订阅者和发布者之间是存在依赖的
  • 发布/订阅模式 由统一调度中心(事件中心)调用,因此发布者和订阅者不需要知道对方的存在
事件中心隔离了发布者和订阅者,去除它们之间的相互依赖。观察者模式中,目标与观察者是相互依赖的,而发布订阅模式中,多了个事件中心。事件中心是隔离发布者和订阅者的,减少发布者和订阅者的依赖关系,会变得更加灵活。

image.png

查看原文

赞 0 收藏 0 评论 0

飘香豆腐 收藏了文章 · 2020-09-17

基于 qiankun 的微前端最佳实践(图文并茂) - 应用间通信篇

micro-app

写在开头

微前端系列文章:

本系列其他文章计划一到两个月内完成,点个 关注 不迷路。

计划如下:

  • 生命周期篇;
  • IE 兼容篇;
  • 生产环境部署篇;
  • 性能优化、缓存方案篇;

引言

大家好~

本文是基于 qiankun 的微前端最佳实践系列文章之 应用间通信篇,本文将分享在 qiankun 中如何进行应用间通信。

在开始介绍 qiankun 的应用通信之前,我们需要先了解微前端架构如何划分子应用。

在微前端架构中,我们应该按业务划分出对应的子应用,而不是通过功能模块划分子应用。这么做的原因有两个:

  1. 在微前端架构中,子应用并不是一个模块,而是一个独立的应用,我们将子应用按业务划分可以拥有更好的可维护性和解耦性。
  2. 子应用应该具备独立运行的能力,应用间频繁的通信会增加应用的复杂度和耦合度。

综上所述,我们应该从业务的角度出发划分各个子应用,尽可能减少应用间的通信,从而简化整个应用,使得我们的微前端架构可以更加灵活可控。

我们本次教程将介绍两种通信方式,

  1. 第一种是 qiankun 官方提供的通信方式 - Actions 通信,适合业务划分清晰,比较简单的微前端应用,一般来说使用第一种方案就可以满足大部分的应用场景需求。
  2. 第二种是基于 redux 实现的通信方式 - Shared 通信,适合需要跟踪通信状态,子应用具备独立运行能力,较为复杂的微前端应用。

Actions 通信

我们先介绍官方提供的应用间通信方式 - Actions 通信,这种通信方式比较适合业务划分清晰,应用间通信较少的微前端应用场景。

通信原理

qiankun 内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通信,该实例有三个方法,分别是:

  • setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,如果检查到 globalState 发生改变则触发通知,通知到所有的 观察者 函数。
  • onGlobalStateChange:注册 观察者 函数 - 响应 globalState 变化,在 globalState 发生改变时触发该 观察者 函数。
  • offGlobalStateChange:取消 观察者 函数 - 该实例不再响应 globalState 变化。

我们来画一张图来帮助大家理解(见下图)

micro-app

我们从上图可以看出,我们可以先注册 观察者 到观察者池中,然后通过修改 globalState 可以触发所有的 观察者 函数,从而达到组件间通信的效果。

实战教程

我们以 实战案例 - feature-communication 分支 (案例是以 Vue 为基座的主应用,接入 ReactVue 两个子应用) 为例,来介绍一下如何使用 qiankun 完成应用间的通信功能。

建议 clone实战案例 - feature-communication 分支 分支代码到本地,运行项目查看实际效果。

主应用的工作

首先,我们在主应用中注册一个 MicroAppStateActions 实例并导出,代码实现如下:

// micro-app-main/src/shared/actions.ts
import { initGlobalState, MicroAppStateActions } from "qiankun";

const initialState = {};
const actions: MicroAppStateActions = initGlobalState(initialState);

export default actions;

在注册 MicroAppStateActions 实例后,我们在需要通信的组件中使用该实例,并注册 观察者 函数,我们这里以登录功能为例,实现如下:

// micro-app-main/src/pages/login/index.vue
import actions from "@/shared/actions";
import { ApiLoginQuickly } from "@/apis";

@Component
export default class Login extends Vue {
  $router!: VueRouter;

  // `mounted` 是 Vue 的生命周期钩子函数,在组件挂载时执行
  mounted() {
    // 注册一个观察者函数
    actions.onGlobalStateChange((state, prevState) => {
      // state: 变更后的状态; prevState: 变更前的状态
      console.log("主应用观察者:token 改变前的值为 ", prevState.token);
      console.log("主应用观察者:登录状态发生改变,改变后的 token 的值为 ", state.token);
    });
  }
  
  async login() {
    // ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
    const result = await ApiLoginQuickly();
    const { token } = result.data.loginQuickly;

    // 登录成功后,设置 token
    actions.setGlobalState({ token });
  }
}

在上面的代码中,我们在 Vue 组件mounted 生命周期钩子函数中注册了一个 观察者 函数,然后定义了一个 login 方法,最后将 login 方法绑定在下图的按钮中(见下图)。

micro-app

此时我们点击 2 次按钮,将触发我们在主应用设置的 观察者 函数(如下图)

micro-app

从上图中我们可以看出:

  • 第一次点击:原 token 值为 undefined,新 token 值为我们最新设置的值;
  • 第二次点击时:原 token 的值是我们上一次设置的值,新 token 值为我们最新设置的值;

从上面可以看出,我们的 globalState 更新成功啦!

最后,我们在 login 方法最后加上一行代码,让我们在登录后跳转到主页,代码实现如下:

async login() {
  //...

  this.$router.push("/");
}

子应用的工作

我们已经完成了主应用的登录功能,将 token 信息记录在了 globalState 中。现在,我们进入子应用,使用 token 获取用户信息并展示在页面中。

我们首先来改造我们的 Vue 子应用,首先我们设置一个 Actions 实例,代码实现如下:

// micro-app-vue/src/shared/actions.js
function emptyAction() {
  // 警告:提示当前使用的是空 Action
  console.warn("Current execute action is empty!");
}

class Actions {
  // 默认值为空 Action
  actions = {
    onGlobalStateChange: emptyAction,
    setGlobalState: emptyAction
  };
  
  /**
   * 设置 actions
   */
  setActions(actions) {
    this.actions = actions;
  }

  /**
   * 映射
   */
  onGlobalStateChange(...args) {
    return this.actions.onGlobalStateChange(...args);
  }

  /**
   * 映射
   */
  setGlobalState(...args) {
    return this.actions.setGlobalState(...args);
  }
}

const actions = new Actions();
export default actions;

我们创建 actions 实例后,我们需要为其注入真实 Actions。我们在入口文件 main.jsrender 函数中注入,代码实现如下:

// micro-app-vue/src/main.js
//...

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
function render(props) {
  if (props) {
    // 注入 actions 实例
    actions.setActions(props);
  }

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

从上面的代码可以看出,挂载子应用时将会调用 render 方法,我们在 render 方法中将主应用的 actions 实例注入即可。

最后我们在子应用的 通讯页 获取 globalState 中的 token,使用 token 来获取用户信息,最后在页面中显示用户信息。代码实现如下:

// micro-app-vue/src/pages/communication/index.vue
// 引入 actions 实例
import actions from "@/shared/actions";
import { ApiGetUserInfo } from "@/apis";

export default {
  name: "Communication",

  data() {
    return {
      userInfo: {}
    };
  },

  mounted() {
    // 注册观察者函数
    // onGlobalStateChange 第二个参数为 true,表示立即执行一次观察者函数
    actions.onGlobalStateChange(state => {
      const { token } = state;
      // 未登录 - 返回主页
      if (!token) {
        this.$message.error("未检测到登录信息!");
        return this.$router.push("/");
      }

      // 获取用户信息
      this.getUserInfo(token);
    }, true);
  },

  methods: {
    async getUserInfo(token) {
      // ApiGetUserInfo 是用于获取用户信息的函数
      const result = await ApiGetUserInfo(token);
      this.userInfo = result.data.getUserInfo;
    }
  }
};

从上面的代码可以看到,我们在组件挂载时注册了一个 观察者 函数并立即执行,从 globalState/state 中获取 token,然后使用 token 获取用户信息,最终渲染在页面中。

最后,我们来看看实际效果。我们从登录页面点击 Login 按钮后,通过菜单进入 Vue 通讯页,就可以看到效果啦!(见下图)

micro-app

React 子应用的实现也是类似的,实现代码可以参照 完整 Demo - feature-communication 分支,实现效果如下(见下图)

micro-app

小结

到这里,qiankun 基础通信 就完成了!

我们在主应用中实现了登录功能,登录拿到 token 后存入 globalState 状态池中。在进入子应用时,我们使用 actions 获取 token,再使用 token 获取到用户信息,完成页面数据渲染!

最后我们画一张图帮助大家理解这个流程(见下图)。

micro-app

Shared 通信

由于 Shared 方案实现起来会较为复杂,所以当 Actions 通信方案满足需求时,使用 Actions 通信方案可以得到更好的官方支持。

官方提供的 Actions 通信方案是通过全局状态池和观察者函数进行应用间通信,该通信方式适合大部分的场景。

Actions 通信方案也存在一些优缺点,优点如下:

  1. 使用简单;
  2. 官方支持性高;
  3. 适合通信较少的业务场景;

缺点如下:

  1. 子应用独立运行时,需要额外配置无 Actions 时的逻辑;
  2. 子应用需要先了解状态池的细节,再进行通信;
  3. 由于状态池无法跟踪,通信场景较多时,容易出现状态混乱、维护困难等问题;

如果你的应用通信场景较多,希望子应用具备完全独立运行能力,希望主应用能够更好的管理子应用,那么可以考虑 Shared 通信方案。

通信原理

Shared 通信方案的原理就是,主应用基于 redux 维护一个状态池,通过 shared 实例暴露一些方法给子应用使用。同时,子应用需要单独维护一份 shared 实例,在独立运行时使用自身的 shared 实例,在嵌入主应用时使用主应用的 shared 实例,这样就可以保证在使用和表现上的一致性。

Shared 通信方案需要自行维护状态池,这样会增加项目的复杂度。好处是可以使用市面上比较成熟的状态管理工具,如 reduxmobx,可以有更好的状态管理追踪和一些工具集。

Shared 通信方案要求父子应用都各自维护一份属于自己的 shared 实例,同样会增加项目的复杂度。好处是子应用可以完全独立于父应用运行(不依赖状态池),子应用也能以最小的改动被嵌入到其他 第三方应用 中。

Shared 通信方案也可以帮助主应用更好的管控子应用。子应用只可以通过 shared 实例来操作状态池,可以避免子应用对状态池随意操作引发的一系列问题。主应用的 Shared 相对于子应用来说是一个黑箱,子应用只需要了解 Shared 所暴露的 API 而无需关心实现细节。

实战教程

我们还是以 实战案例 - feature-communication-shared 分支登录流程 为例,给大家展示如何使用 Shared 进行应用间通信。

主应用的工作

首先我们需要在主应用中创建 store 用于管理全局状态池,这里我们使用 redux 来实现,代码实现如下:

// micro-app-main/src/shared/store.ts
import { createStore } from "redux";

export type State = {
  token?: string;
};

type Action = {
  type: string;
  payload: any;
};

const reducer = (state: State = {}, action: Action): State => {
  switch (action.type) {
    default:
      return state;
    // 设置 Token
    case "SET_TOKEN":
      return {
        ...state,
        token: action.payload,
      };
  }
};

const store = createStore<State, Action, unknown, unknown>(reducer);

export default store;

从上面可以看出,我们使用 redux 创建了一个全局状态池,并设置了一个 reducer 用于修改 token 的值。接下来我们需要实现主应用的 shared 实例,代码实现如下:

// micro-app-main/src/shared/index.ts
import store from "./store";

class Shared {
  /**
   * 获取 Token
   */
  public getToken(): string {
    const state = store.getState();
    return state.token || "";
  }

  /**
   * 设置 Token
   */
  public setToken(token: string): void {
    // 将 token 的值记录在 store 中
    store.dispatch({
      type: "SET_TOKEN",
      payload: token
    });
  }
}

const shared = new Shared();
export default shared;

从上面实现可以看出,我们的 shared 实现非常简单,shared 实例包括两个方法 getTokensetToken 分别用于获取 token 和设置 token。接下来我们还需要对我们的 登录组件 进行改造,将 login 方法修改一下,修改如下:

// micro-app-main/src/pages/login/index.vue
// ...
async login() {
  // ApiLoginQuickly 是一个远程登录函数,用于获取 token,详见 Demo
  const result = await ApiLoginQuickly();
  const { token } = result.data.loginQuickly;

  // 使用 shared 的 setToken 方法记录 token
  shared.setToken(token);
  this.$router.push("/");
}

从上面可以看出,在登录成功后,我们将通过 shared.setToken 方法将 token 记录在 store 中。

最后,我们需要将 shared 实例通过 props 传递给子应用,代码实现如下:

// micro-app-main/src/micro/apps.ts
import shared from "@/shared";

const apps = [
  {
    name: "ReactMicroApp",
    entry: "//localhost:10100",
    container: "#frame",
    activeRule: "/react",
    // 通过 props 将 shared 传递给子应用
    props: { shared },
  },
  {
    name: "VueMicroApp",
    entry: "//localhost:10200",
    container: "#frame",
    activeRule: "/vue",
    // 通过 props 将 shared 传递给子应用
    props: { shared },
  },
];

export default apps;

子应用的工作

现在,我们来处理子应用需要做的工作。我们刚才提到,我们希望子应用有独立运行的能力,所以子应用也应该实现 shared,以便在独立运行时可以拥有兼容处理能力。代码实现如下:

// micro-app-vue/src/shared/index.js
class Shared {
  /**
   * 获取 Token
   */
  getToken() {
    // 子应用独立运行时,在 localStorage 中获取 token
    return localStorage.getItem("token") || "";
  }

  /**
   * 设置 Token
   */
  setToken(token) {
    // 子应用独立运行时,在 localStorage 中设置 token
    localStorage.setItem("token", token);
  }
}

class SharedModule {
  static shared = new Shared();

  /**
   * 重载 shared
   */
  static overloadShared(shared) {
    SharedModule.shared = shared;
  }

  /**
   * 获取 shared 实例
   */
  static getShared() {
    return SharedModule.shared;
  }
}

export default SharedModule;

从上面我们可以看到两个类,我们来分析一下其用处:

  • Shared:子应用自身的 shared,子应用独立运行时将使用该 shared,子应用的 shared 使用 localStorage 来操作 token
  • SharedModule:用于管理 shared,例如重载 shared 实例、获取 shared 实例等等;

我们实现了子应用的 shared 后,我们需要在入口文件处注入 shared,代码实现如下:

// micro-app-vue/src/main.js
//...

/**
 * 渲染函数
 * 主应用生命周期钩子中运行/子应用单独启动时运行
 */
function render(props = {}) {
  // 当传入的 shared 为空时,使用子应用自身的 shared
  // 当传入的 shared 不为空时,主应用传入的 shared 将会重载子应用的 shared
  const { shared = SharedModule.getShared() } = props;
  SharedModule.overloadShared(shared);

  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

从上面可以看出,我们在 propsshared 字段不为空时,将会使用传入的 shared 重载子应用自身的 shared。这样做的话,主应用的 shared 和子应用的 shared 在使用时的表现是一致的。

然后我们修改子应用的 通讯页,使用 shared 实例获取 token,代码实现如下:

// micro-app-vue/src/pages/communication/index.vue
// 引入 SharedModule
import SharedModule from "@/shared";
import { ApiGetUserInfo } from "@/apis";

export default {
  name: "Communication",

  data() {
    return {
      userInfo: {}
    };
  },

  mounted() {
    const shared = SharedModule.getShared();
    // 使用 shared 获取 token
    const token = shared.getToken();

    // 未登录 - 返回主页
    if (!token) {
      this.$message.error("未检测到登录信息!");
      return this.$router.push("/");
    }

    this.getUserInfo(token);
  },

  methods: {
    async getUserInfo(token) {
      // ApiGetUserInfo 是用于获取用户信息的函数
      const result = await ApiGetUserInfo(token);
      this.userInfo = result.data.getUserInfo;
    }
  }
};

最后我们打开页面,看看在主应用中运行和独立运行时的表现吧!(见下图)

micro-app

micro-app

上图 1 可以看出,我们在主应用中运行子应用时,shared 实例被主应用重载,登录后可以在状态池中获取到 token,并且使用 token 成功获取了用户信息。

上图 2 可以看出,在我们独立运行子应用时,shared 实例是子应用自身的 shared,在 localStorage 中无法获取到 token,被拦截返回到主页。

这样一来,我们就完成了 Shared 通信啦!

小结

我们从上面的案例也可以看出 Shared 通信方案的优缺点,这里也做一些简单的分析:

优点有这些:

  • 可以自由选择状态管理库,更好的开发体验。 - 比如 redux 有专门配套的开发工具可以跟踪状态的变化。
  • 子应用无需了解主应用的状态池实现细节,只需要了解 shared 的函数抽象,实现一套自身的 shared 甚至空 shared 即可,可以更好的规范子应用开发。
  • 子应用无法随意污染主应用的状态池,只能通过主应用暴露的 shared 实例的特定方法操作状态池,从而避免状态池污染产生的问题。
  • 子应用将具备独立运行的能力,Shared 通信使得父子应用有了更好的解耦性。

缺点也有两个:

  • 主应用需要单独维护一套状态池,会增加维护成本和项目复杂度;
  • 子应用需要单独维护一份 shared 实例,会增加维护成本;

Shared 通信方式也是有利有弊,更高的维护成本带来的是应用的健壮性和可维护性。

最后我们来画一张图对 shared 通信的原理和流程进行解析(见下图)

micro-app

总结

到这里,两种 qiankun 应用间通信方案就分享完啦!

两种通信方案都有合适的使用场景,大家可以结合自己的需要选择即可。

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

personal

查看原文

飘香豆腐 收藏了文章 · 2020-09-17

基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇

micro-app

写在开头

微前端系列文章:

本系列其他文章计划一到两个月内完成,点个 关注 不迷路。

计划如下:

  • 生命周期篇;
  • IE 兼容篇;
  • 生产环境部署篇;
  • 性能优化、缓存方案篇;

引言

大家好~

本文是基于 qiankun 的微前端最佳实践系列文章之 从 0 到 1 篇,本文将分享如何使用 qiankun 如何搭建主应用基座,然后接入不同技术栈的微应用,完成微前端架构的从 0 到 1。

本教程采用 Vue 作为主应用基座,接入不同技术栈的微应用。如果你不懂 Vue 也没关系,我们在搭建主应用基座的教程尽量不涉及 VueAPI,涉及到 API 的地方都会给出解释。

注意:qiankun 属于无侵入性的微前端框架,对主应用基座和微应用的技术栈都没有要求。

我们在本教程中,接入了多技术栈 微应用主应用 最终效果图如下:

micro-app

构建主应用基座

我们以 实战案例 - feature-inject-sub-apps 分支 (案例是以 Vue 为基座的主应用,接入多个微应用) 为例,来介绍一下如何在 qiankun 中如何接入不同技术栈的微应用。

我们先使用 vue-cli 生成一个 Vue 的项目,初始化主应用。

vue-cliVue 官方提供的脚手架工具,用于快速搭建一个 Vue 项目。如果你想跳过这一步,可以直接 clone实战案例 - feature-inject-sub-apps 分支 的代码。

将普通的项目改造成 qiankun 主应用基座,需要进行三步操作:

  1. 创建微应用容器 - 用于承载微应用,渲染显示微应用;
  2. 注册微应用 - 设置微应用激活条件,微应用地址等等;
  3. 启动 qiankun

创建微应用容器

我们先在主应用中创建微应用的承载容器,这个容器规定了微应用的显示区域,微应用将在该容器内渲染并显示。

我们先设置路由,路由文件规定了主应用自身的路由匹配规则,代码实现如下:

// micro-app-main/src/routes/index.ts
import Home from "@/pages/home/index.vue";

const routes = [
  {
    /**
     * path: 路径为 / 时触发该路由规则
     * name: 路由的 name 为 Home
     * component: 触发路由时加载 `Home` 组件
     */
    path: "/",
    name: "Home",
    component: Home,
  },
];

export default routes;

// micro-app-main/src/main.ts
//...
import Vue from "vue";
import VueRouter from "vue-router";

import routes from "./routes";

/**
 * 注册路由实例
 * 即将开始监听 location 变化,触发路由规则
 */
const router = new VueRouter({
  mode: "history",
  routes,
});

// 创建 Vue 实例
// 该实例将挂载/渲染在 id 为 main-app 的节点上
new Vue({
  router,
  render: (h) => h(App),
}).$mount("#main-app");

从上面代码可以看出,我们设置了主应用的路由规则,设置了 Home 主页的路由匹配规则。

我们现在来设置主应用的布局,我们会有一个菜单和显示区域,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
  ];
}

上面的代码是我们对菜单配置的实现,我们还需要实现基座和微应用的显示区域(如下图)

micro-app

我们来分析一下上面的代码:

  • 第 5 行:主应用菜单,用于渲染菜单;
  • 第 9 行:主应用渲染区。在触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染主应用的组件;
  • 第 10 行:微应用渲染区。在未触发主应用路由规则时(由路由配置表的 $route.name 判断),将渲染微应用节点;

从上面的分析可以看出,我们使用了在路由表配置的 name 字段进行判断,判断当前路由是否为主应用路由,最后决定渲染主应用组件或是微应用节点。

由于篇幅原因,样式实现代码就不贴出来了,最后主应用的实现效果如下图所示:

micro-app

从上图可以看出,我们主应用的组件和微应用是显示在同一片内容区域,根据路由规则决定渲染规则。

注册微应用

在构建好了主框架后,我们需要使用 qiankunregisterMicroApps 方法注册微应用,代码实现如下:

// micro-app-main/src/micro/apps.ts
// 此时我们还没有微应用,所以 apps 为空
const apps = [];

export default apps;

// micro-app-main/src/micro/index.ts
// 一个进度条插件
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { message } from "ant-design-vue";
import {
  registerMicroApps,
  addGlobalUncaughtErrorHandler,
  start,
} from "qiankun";

// 微应用注册信息
import apps from "./apps";

/**
 * 注册微应用
 * 第一个参数 - 微应用的注册信息
 * 第二个参数 - 全局生命周期钩子
 */
registerMicroApps(apps, {
  // qiankun 生命周期钩子 - 微应用加载前
  beforeLoad: (app: any) => {
    // 加载微应用前,加载进度条
    NProgress.start();
    console.log("before load", app.name);
    return Promise.resolve();
  },
  // qiankun 生命周期钩子 - 微应用挂载后
  afterMount: (app: any) => {
    // 加载微应用前,进度条加载完成
    NProgress.done();
    console.log("after mount", app.name);
    return Promise.resolve();
  },
});

/**
 * 添加全局的未捕获异常处理器
 */
addGlobalUncaughtErrorHandler((event: Event | string) => {
  console.error(event);
  const { message: msg } = event as any;
  // 加载失败时提示
  if (msg && msg.includes("died in status LOADING_SOURCE_CODE")) {
    message.error("微应用加载失败,请检查应用是否可运行");
  }
});

// 导出 qiankun 的启动函数
export default start;

从上面可以看出,我们的微应用注册信息在 apps 数组中(此时为空,我们在后面接入微应用时会添加微应用注册信息),然后使用 qiankunregisterMicroApps 方法注册微应用,最后导出了 start 函数,注册微应用的工作就完成啦!

启动主应用

我们在注册好了微应用,导出 start 函数后,我们需要在合适的地方调用 start 启动主应用。

我们一般是在入口文件启动 qiankun 主应用,代码实现如下:

// micro-app-main/src/main.ts
//...
import startQiankun from "./micro";

startQiankun();

最后,启动我们的主应用,效果图如下:

micro-app

因为我们还没有注册任何微应用,所以这里的效果图和上面的效果图是一样的。

到这一步,我们的主应用基座就创建好啦!

接入微应用

我们现在的主应用基座只有一个主页,现在我们需要接入微应用。

qiankun 内部通过 import-entry-html 加载微应用,要求微应用需要导出生命周期钩子函数(见下图)。

micro-app

从上图可以看出,qiankun 内部会校验微应用的生命周期钩子函数,如果微应用没有导出这三个生命周期钩子函数,则微应用会加载失败。

如果我们使用了脚手架搭建微应用的话,我们可以通过 webpack 配置在入口文件处导出这三个生命周期钩子函数。如果没有使用脚手架的话,也可以直接在微应用的 window 上挂载这三个生命周期钩子函数。

现在我们来接入我们的各个技术栈微应用吧!

注意,下面的内容对相关技术栈 API 不会再有过多介绍啦,如果你要接入不同技术栈的微应用,最好要对该技术栈有一些基础了解。

接入 Vue 微应用

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 vue-cli 先创建一个 Vue 的项目,在命令行运行如下命令:

vue create micro-app-vue

本文的 vue-cli 选项如下图所示,你也可以根据自己的喜好选择配置。

micro-app

在新建项目完成后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 Vue 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "VueMicroApp",
    entry: "//localhost:10200",
    container: "#frame",
    activeRule: "/vue",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Vue 微应用,进入 /vue 路由时将加载我们的 Vue 微应用。

我们在菜单配置处也加入 Vue 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "VueMicroApp",
      title: "Vue 主页",
      path: "/vue",
    },
    {
      key: "VueMicroAppList",
      title: "Vue 列表页",
      path: "/vue/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 Vue 的入口文件 main.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 6 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 21 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 38 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 46 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 53 行:微应用导出的生命周期钩子函数 - mount
  • 第 61 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-vue/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-vue/src/main.js
import Vue from "vue";
import VueRouter from "vue-router";
import Antd from "ant-design-vue";
import "ant-design-vue/dist/antd.css";

import "./public-path";
import App from "./App.vue";
import routes from "./routes";

Vue.use(VueRouter);
Vue.use(Antd);
Vue.config.productionTip = false;

let instance = null;
let router = null;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  // 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
  router = new VueRouter({
    // 运行在主应用中时,添加路由命名空间 /vue
    base: window.__POWERED_BY_QIANKUN__ ? "/vue" : "/",
    mode: "history",
    routes,
  });

  // 挂载应用
  instance = new Vue({
    router,
    render: (h) => h(App),
  }).$mount("#app");
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("VueMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("VueMicroApp mount", props);
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  console.log("VueMicroApp unmount");
  instance.$destroy();
  instance = null;
  router = null;
}

在配置好了入口文件 main.js 后,我们还需要配置 webpack,使 main.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们直接配置 vue.config.js 即可,代码实现如下:

// micro-app-vue/vue.config.js
const path = require("path");

module.exports = {
  devServer: {
    // 监听端口
    port: 10200,
    // 关闭主机检查,使微应用可以被 fetch
    disableHostCheck: true,
    // 配置跨域请求头,解决开发环境的跨域问题
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  configureWebpack: {
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "src"),
      },
    },
    output: {
      // 微应用的包名,这里与主应用中注册的微应用名称一致
      library: "VueMicroApp",
      // 将你的 library 暴露为所有的模块定义下都可运行的方式
      libraryTarget: "umd",
      // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
      jsonpFunction: `webpackJsonp_VueMicroApp`,
    },
  },
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

vue.config.js 修改完成后,我们重新启动 Vue 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 Vue 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Vue 微应用就接入成功了!

接入 React 微应用

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 react-create-app 先创建一个 React 的项目,在命令行运行如下命令:

npx create-react-app micro-app-react

在项目创建完成后,我们在根目录下添加 .env 文件,设置项目监听的端口,代码实现如下:

# micro-app-react/.env
PORT=10100
BROWSER=none

然后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 React 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "ReactMicroApp",
    entry: "//localhost:10100",
    container: "#frame",
    activeRule: "/react",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 React 微应用,进入 /react 路由时将加载我们的 React 微应用。

我们在菜单配置处也加入 React 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "ReactMicroApp",
      title: "React 主页",
      path: "/react",
    },
    {
      key: "ReactMicroAppList",
      title: "React 列表页",
      path: "/react/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要对微应用进行一系列的配置。首先,我们在 React 的入口文件 index.js 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 5 行webpack 默认的 publicPath"" 空字符串,会基于当前路径来加载资源。我们在主应用中加载微应用时需要重新设置 publicPath,这样才能正确加载微应用的相关资源。(public-path.js 具体实现在后面)
  • 第 12 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 17 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 25 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 32 行:微应用导出的生命周期钩子函数 - mount
  • 第 40 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-react/src/public-path.js
if (window.__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

// micro-app-react/src/index.js
import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";

import "./public-path";
import App from "./App.jsx";

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
function render() {
  ReactDOM.render(<App />, document.getElementById("root"));
}

// 独立运行时,直接挂载应用
if (!window.__POWERED_BY_QIANKUN__) {
  render();
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("ReactMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log("ReactMicroApp mount", props);
  render(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  console.log("ReactMicroApp unmount");
  ReactDOM.unmountComponentAtNode(document.getElementById("root"));
}

在配置好了入口文件 index.js 后,我们还需要配置路由命名空间,以确保主应用可以正确加载微应用,代码实现如下:

// micro-app-react/src/App.jsx
const BASE_NAME = window.__POWERED_BY_QIANKUN__ ? "/react" : "";
const App = () => {
  //...

  return (
    // 设置路由命名空间
    <Router basename={BASE_NAME}>{/* ... */}</Router>
  );
};

接下来,我们还需要配置 webpack,使 index.js 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们需要借助 react-app-rewired 来帮助我们修改 webpack 的配置,我们直接安装该插件:

npm install react-app-rewired -D

react-app-rewired 安装完成后,我们还需要修改 package.jsonscripts 选项,修改为由 react-app-rewired 启动应用,就像下面这样

// micro-app-react/package.json

//...
"scripts": {
  "start": "react-app-rewired start",
  "build": "react-app-rewired build",
  "test": "react-app-rewired test",
  "eject": "react-app-rewired eject"
}

react-app-rewired 配置完成后,我们新建 config-overrides.js 文件来配置 webpack,代码实现如下:

const path = require("path");

module.exports = {
  webpack: (config) => {
    // 微应用的包名,这里与主应用中注册的微应用名称一致
    config.output.library = `ReactMicroApp`;
    // 将你的 library 暴露为所有的模块定义下都可运行的方式
    config.output.libraryTarget = "umd";
    // 按需加载相关,设置为 webpackJsonp_VueMicroApp 即可
    config.output.jsonpFunction = `webpackJsonp_ReactMicroApp`;

    config.resolve.alias = {
      ...config.resolve.alias,
      "@": path.resolve(__dirname, "src"),
    };
    return config;
  },

  devServer: function (configFunction) {
    return function (proxy, allowedHost) {
      const config = configFunction(proxy, allowedHost);
      // 关闭主机检查,使微应用可以被 fetch
      config.disableHostCheck = true;
      // 配置跨域请求头,解决开发环境的跨域问题
      config.headers = {
        "Access-Control-Allow-Origin": "*",
      };
      // 配置 history 模式
      config.historyApiFallback = true;

      return config;
    };
  },
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

config-overrides.js 修改完成后,我们重新启动 React 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 React 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,React 微应用就接入成功了!

接入 Angular 微应用

Angularqiankun 目前的兼容性并不太好,接入 Angular 微应用需要一定的耐心与技巧。

对于选择 Angular 技术栈的前端开发来说,对这类情况应该驾轻就熟(没有办法)。

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),使用 @angular/cli 先创建一个 Angular 的项目,在命令行运行如下命令:

ng new micro-app-angular

本文的 @angular/cli 选项如下图所示,你也可以根据自己的喜好选择配置。

micro-app

然后,我们创建几个路由页面再加上一些样式,最后效果如下:

micro-app

micro-app

注册微应用

在创建好了 Angular 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "AngularMicroApp",
    entry: "//localhost:10300",
    container: "#frame",
    activeRule: "/angular",
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Angular 微应用,进入 /angular 路由时将加载我们的 Angular 微应用。

我们在菜单配置处也加入 Angular 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/",
    },
    {
      key: "AngularMicroApp",
      title: "Angular 主页",
      path: "/angular",
    },
    {
      key: "AngularMicroAppList",
      title: "Angular 列表页",
      path: "/angular/list",
    },
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

最后我们在主应用的入口文件,引入 zone.js,代码实现如下:

Angular 运行依赖于 zone.js

qiankun 基于 single-spa 实现,single-spa 明确指出一个项目的 zone.js 只能存在一份实例,所以我们在主应用注入 zone.js

// micro-app-main/src/main.js

// 为 Angular 微应用所做的 zone 包注入
import "zone.js/dist/zone";

配置微应用

在主应用的工作完成后,我们还需要对微应用进行一系列的配置。首先,我们使用 single-spa-angular 生成一套配置,在命令行运行以下命令:

# 安装 single-spa
yarn add single-spa -S

# 添加 single-spa-angular
ng add single-spa-angular

运行命令时,根据自己的需求选择配置即可,本文配置如下:

micro-app

在生成 single-spa 配置后,我们需要进行一些 qiankun 的接入配置。我们在 Angular 微应用的入口文件 main.single-spa.ts 中,导出 qiankun 主应用所需要的三个生命周期钩子函数,代码实现如下:

micro-app

从上图来分析:

  • 第 21 行:微应用独立运行时,直接执行挂载函数挂载微应用。
  • 第 46 行:微应用导出的生命周期钩子函数 - bootstrap
  • 第 50 行:微应用导出的生命周期钩子函数 - mount
  • 第 54 行:微应用导出的生命周期钩子函数 - unmount

完整代码实现如下:

// micro-app-angular/src/main.single-spa.ts
import { enableProdMode, NgZone } from "@angular/core";

import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";
import { Router } from "@angular/router";
import { ɵAnimationEngine as AnimationEngine } from "@angular/animations/browser";

import {
  singleSpaAngular,
  getSingleSpaExtraProviders,
} from "single-spa-angular";

import { AppModule } from "./app/app.module";
import { environment } from "./environments/environment";
import { singleSpaPropsSubject } from "./single-spa/single-spa-props";

if (environment.production) {
  enableProdMode();
}

// 微应用单独启动时运行
if (!(window as any).__POWERED_BY_QIANKUN__) {
  platformBrowserDynamic()
    .bootstrapModule(AppModule)
    .catch((err) => console.error(err));
}

const { bootstrap, mount, unmount } = singleSpaAngular({
  bootstrapFunction: (singleSpaProps) => {
    singleSpaPropsSubject.next(singleSpaProps);
    return platformBrowserDynamic(getSingleSpaExtraProviders()).bootstrapModule(
      AppModule
    );
  },
  template: "<app-root />",
  Router,
  NgZone,
  AnimationEngine,
});

/** 主应用生命周期钩子中运行 */
export {
  /**
   * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
   * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
   */
  bootstrap,
  /**
   * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
   */
  mount,
  /**
   * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
   */
  unmount,
};

在配置好了入口文件 main.single-spa.ts 后,我们还需要配置 webpack,使 main.single-spa.ts 导出的生命周期钩子函数可以被 qiankun 识别获取。

我们直接配置 extra-webpack.config.js 即可,代码实现如下:

// micro-app-angular/extra-webpack.config.js
const singleSpaAngularWebpack = require("single-spa-angular/lib/webpack")
  .default;
const webpackMerge = require("webpack-merge");

module.exports = (angularWebpackConfig, options) => {
  const singleSpaWebpackConfig = singleSpaAngularWebpack(
    angularWebpackConfig,
    options
  );

  const singleSpaConfig = {
    output: {
      // 微应用的包名,这里与主应用中注册的微应用名称一致
      library: "AngularMicroApp",
      // 将你的 library 暴露为所有的模块定义下都可运行的方式
      libraryTarget: "umd",
    },
  };
  const mergedConfig = webpackMerge.smart(
    singleSpaWebpackConfig,
    singleSpaConfig
  );
  return mergedConfig;
};

我们需要重点关注一下 output 选项,当我们把 libraryTarget 设置为 umd 后,我们的 library 就暴露为所有的模块定义下都可运行的方式了,主应用就可以获取到微应用的生命周期钩子函数了。

extra-webpack.config.js 修改完成后,我们还需要修改一下 package.json 中的启动命令,修改如下:

// micro-app-angular/package.json
{
  //...
  "script": {
    //...
    // --disable-host-check: 关闭主机检查,使微应用可以被 fetch
    // --port: 监听端口
    // --base-href: 站点的起始路径,与主应用中配置的一致
    "start": "ng serve --disable-host-check --port 10300 --base-href /angular"
  }
}

修改完成后,我们重新启动 Angular 微应用,然后打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时我们的 Angular 微应用被正确加载啦!(见下图)

micro-app

到这里,Angular 微应用就接入成功了!

接入 Jquery、xxx... 微应用

这里的 Jquery、xxx... 微应用指的是没有使用脚手架,直接采用 html + css + js 三剑客开发的应用。

本案例使用了一些高级 ES 语法,请使用谷歌浏览器运行查看效果。

我们以 实战案例 - feature-inject-sub-apps 分支 为例,我们在主应用的同级目录(micro-app-main 同级目录),手动创建目录 micro-app-static

我们使用 express 作为服务器加载静态 html,我们先编辑 package.json,设置启动命令和相关依赖。

// micro-app-static/package.json
{
  "name": "micro-app-jquery",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "nodemon index.js"
  },
  "author": "",
  "license": "ISC",
  "dependencies": {
    "express": "^4.17.1",
    "cors": "^2.8.5"
  },
  "devDependencies": {
    "nodemon": "^2.0.2"
  }
}

然后添加入口文件 index.js,代码实现如下:

// micro-app-static/index.js
const express = require("express");
const cors = require("cors");

const app = express();
// 解决跨域问题
app.use(cors());
app.use('/', express.static('static'));

// 监听端口
app.listen(10400, () => {
  console.log("server is listening in http://localhost:10400")
});

使用 npm install 安装相关依赖后,我们使用 npm start 启动应用。

我们新建 static 文件夹,在文件夹内新增一个静态页面 index.html(代码在后面会贴出),加上一些样式后,打开浏览器,最后效果如下:

micro-app

注册微应用

在创建好了 Static 微应用后,我们可以开始我们的接入工作了。首先我们需要在主应用中注册该微应用的信息,代码实现如下:

// micro-app-main/src/micro/apps.ts
const apps = [
  /**
   * name: 微应用名称 - 具有唯一性
   * entry: 微应用入口 - 通过该地址加载微应用
   * container: 微应用挂载节点 - 微应用加载完成后将挂载在该节点上
   * activeRule: 微应用触发的路由规则 - 触发路由规则后将加载该微应用
   */
  {
    name: "StaticMicroApp",
    entry: "//localhost:10400",
    container: "#frame",
    activeRule: "/static"
  },
];

export default apps;

通过上面的代码,我们就在主应用中注册了我们的 Static 微应用,进入 /static 路由时将加载我们的 Static 微应用。

我们在菜单配置处也加入 Static 微应用的快捷入口,代码实现如下:

// micro-app-main/src/App.vue
//...
export default class App extends Vue {
  /**
   * 菜单列表
   * key: 唯一 Key 值
   * title: 菜单标题
   * path: 菜单对应的路径
   */
  menus = [
    {
      key: "Home",
      title: "主页",
      path: "/"
    },
    {
      key: "StaticMicroApp",
      title: "Static 微应用",
      path: "/static"
    }
  ];
}

菜单配置完成后,我们的主应用基座效果图如下

micro-app

配置微应用

在主应用注册好了微应用后,我们还需要直接写微应用 index.html 的代码即可,代码实现如下:

micro-app

从上图来分析:

  • 第 70 行:微应用的挂载函数,在主应用中运行时将在 mount 生命周期钩子函数中调用,可以保证在沙箱内运行。
  • 第 77 行:微应用独立运行时,直接执行 render 函数挂载微应用。
  • 第 88 行:微应用注册的生命周期钩子函数 - bootstrap
  • 第 95 行:微应用注册的生命周期钩子函数 - mount
  • 第 102 行:微应用注册的生命周期钩子函数 - unmount

完整代码实现如下:

<!-- micro-app-static/static/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <!-- 引入 bootstrap -->
    <link
      href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/4.4.1/css/bootstrap.min.css"
      rel="stylesheet"
    />
    <title>Jquery App</title>
  </head>

  <body>
    <section
      id="jquery-app-container"
      style="padding: 20px; color: blue;"
    ></section>
  </body>
  <!-- 引入 jquery -->
  <script data-original="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
  <script>
    /**
     * 请求接口数据,构建 HTML
     */
    async function buildHTML() {
      const result = await fetch("http://dev-api.jt-gmall.com/mall", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        // graphql 的查询风格
        body: JSON.stringify({
          query: `{ vegetableList (page: 1, pageSize: 20) { page, pageSize, total, items { _id, name, poster, price } } }`,
        }),
      }).then((res) => res.json());
      const list = result.data.vegetableList.items;
      const html = `<table class="table">
  <thead>
    <tr>
      <th scope="col">菜名</th>
      <th scope="col">图片</th>
      <th scope="col">报价</th>
    </tr>
  </thead>
  <tbody>
    ${list
      .map(
        (item) => `
    <tr>
      <td>
        <img style="width: 40px; height: 40px; border-radius: 100%;" data-original="${item.poster}"></img>
      </td>
      <td>${item.name}</td>
      <td>¥ ${item.price}</td>
    </tr>
      `
      )
      .join("")}
  </tbody>
</table>`;
      return html;
    }

    /**
     * 渲染函数
     * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
     */
    const render = async ($) => {
      const html = await buildHTML();
      $("#jquery-app-container").html(html);
      return Promise.resolve();
    };

    // 独立运行时,直接挂载应用
    if (!window.__POWERED_BY_QIANKUN__) {
      render($);
    }

    ((global) => {
      /**
       * 注册微应用生命周期钩子函数
       * global[appName] 中的 appName 与主应用中注册的微应用名称一致
       */
      global["StaticMicroApp"] = {
        /**
         * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
         * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
         */
        bootstrap: () => {
          console.log("MicroJqueryApp bootstraped");
          return Promise.resolve();
        },
        /**
         * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
         */
        mount: () => {
          console.log("MicroJqueryApp mount");
          return render($);
        },
        /**
         * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
         */
        unmount: () => {
          console.log("MicroJqueryApp unmount");
          return Promise.resolve();
        },
      };
    })(window);
  </script>
</html>

在构建好了 Static 微应用后,我们打开主应用基座 http://localhost:9999。我们点击左侧菜单切换到微应用,此时可以看到,我们的 Static 微应用被正确加载啦!(见下图)

micro-app

此时我们打开控制台,可以看到我们所执行的生命周期钩子函数(见下图)

micro-app

到这里,Static 微应用就接入成功了!

扩展阅读

如果在 Static 微应用的 html 中注入 SPA 路由功能的话,将演变成单页应用,只需要在主应用中注册一次。

如果是多个 html 的多页应用 - MPA,则需要在服务器(或反向代理服务器)中通过 referer 头返回对应的 html 文件,或者在主应用中注册多个微应用(不推荐)。

小结

最后,我们所有微应用都注册在主应用和主应用的菜单中,效果图如下:

micro-app

从上图可以看出,我们把不同技术栈 Vue、React、Angular、Jquery... 的微应用都已经接入到主应用基座中啦!

最后一件事

如果您已经看到这里了,希望您还是点个赞再走吧~

您的点赞是对作者的最大鼓励,也可以让更多人看到本篇文章!

如果觉得本文对您有帮助,请帮忙在 github 上点亮 star 鼓励一下吧!

personal

查看原文

认证与成就

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

擅长技能
编辑

(゚∀゚ )
暂时没有

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 2017-04-14
个人主页被 499 人浏览