naice

naice 查看完整档案

深圳编辑  |  填写毕业院校腾讯  |  前端开发 编辑 blog.naice.me/ 编辑
编辑

很多事情不是因为有希望才去坚持,而是坚持了才有希望。

个人动态

naice 收藏了文章 · 7月29日

基于 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

查看原文

naice 发布了文章 · 7月13日

完全理解vue的渲染watcher、computed和user watcher

这篇文章将带大家全面理解vue渲染watchercomputeduser watcher,其实computeduser watcher都是基于Watcher来实现的,我们通过一个一个功能点去敲代码,让大家全面理解其中的实现原理和核心思想。所以这篇文章将实现以下这些功能点:

  • 实现数据响应式
  • 基于渲染wather实现首次数据渲染到界面上
  • 数据依赖收集和更新
  • 数据更新回触发渲染watcher执行,从而更新ui界面
  • 基于watcher实现computed
  • 基于watcher实现user watcher

废话不要多说,先看下面的最终例子。

例子看完之后我们就直接开工了。

准备工作

首先我们准备了一个index.html文件和一个vue.js文件,先看看index.html的代码

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>全面理解vue的渲染watcher、computed和user atcher</title>
</head>
<body>
  <div id="root"></div>
  <script data-original="./vue.js"></script>
  <script>
    const root = document.querySelector('#root')
    var vue = new Vue({
      data() {
        return {
          name: '张三',
          age: 10
        }
      },
      render() {
        root.innerHTML = `${this.name}----${this.age}`
      }
    })
  </script>
</body>
</html>

index.html里面分别有一个id是root的div节点,这是跟节点,然后在script标签里面,引入了vue.js,里面提供了Vue构造函数,然后就是实例化Vue,参数是一个对象,对象里面分别有data 和 render 函数。然后我们看看vue.js的代码:

function Vue (options) {
  // 初始化
  this._init(options)
  // 执行render函数
  this.$mount()
}
Vue.prototype._init = function (options) {
  const vm = this
  // 把options挂载到this上
  vm.$options = options
  if (options.data) {
    // 数据响应式
    initState(vm)
  }
  if (options.computed) {
    // 初始化计算属性
    initComputed(vm)
  }
  if (options.watch) {
    // 初始化watch
    initWatch(vm)
  }
}

vue.js代码里面就是执行this._init()this.$mount(),然后_init的方法就是对我们的传进来的配置进行各种初始化,包括数据初始化initState(vm)、计算属性初始化initComputed(vm)、自定义watch初始化initWatch(vm)this.$mount方法把render函数渲染到页面中去、这些方法我们后面都写到,先让让大家了解整个代码结构。下面我们正式去填满我们上面写的这些方法。

实现数据响应式

要实现这些watcher首先去实现数据响应式,也就是要实现上面的initState(vm)这个函数。相信大家都很熟悉响应式这些代码,下面我直接贴上来。

function initState(vm) {
  // 拿到配置的data属性值
  let data = vm.$options.data;
  // 判断data 是函数还是别的类型
  data = vm._data = typeof data === 'function' ? data.call(vm, vm) : data || {};
  // 数据代理
  const keys = Object.keys(data);
  let i = keys.length;
  while(i--) {
    // 从this上读取的数据全部拦截到this._data到里面读取
    // 例如 this.name 等同于  this._data.name
    proxy(vm, '_data', keys[i]);
  }
  // 数据观察
  observe(data);
}

// 数据观察函数
function observe(data) {
  if (typeof data !== 'object' && data != null) {
    return;
  }
  return new Observer(data)
}

// 从this上读取的数据全部拦截到this._data到里面读取
// 例如 this.name 等同于  this._data.name
function proxy(vm, source, key) {
  Object.defineProperty(vm, key, {
    get() {
      return vm[source][key] // this.name 等同于  this._data.name
    },
    set(newValue) {
      return vm[source][key] = newValue
    }
  })
}

class Observer{
  constructor(value) {
    // 给每一个属性都设置get set
    this.walk(value)
  }
  walk(data) {
    let keys = Object.keys(data);
    for (let i = 0, len = keys.length; i < len; i++) {
      let key = keys[i]
      let value = data[key]
      // 给对象设置get set
      defineReactive(data, key, value)
    }
  }
}

function defineReactive(data, key, value) {
  Object.defineProperty(data, key, {
    get() {
      return value
    },
    set(newValue) {
      if (newValue == value) return
      observe(newValue) // 给新的值设置响应式
      value = newValue
    }
  })
  // 递归给数据设置get set
  observe(value);
}

重要的点都在注释里面,主要核心就是给递归给data里面的数据设置getset,然后设置数据代理,让 this.name 等同于 this._data.name。设置完数据观察,我们就可以看到如下图的数据了。

console.log(vue.name) // 张三
console.log(vue.age) // 10
ps: 数组的数据观察大家自行去完善哈,这里重点讲的是watcher的实现。

首次渲染

数据观察搞定了之后,我们就可以把render函数渲染到我们的界面上了。在Vue里面我们有一个this.$mount()函数,所以要实现Vue.prototype.$mount函数:

// 挂载方法
Vue.prototype.$mount = function () {
  const vm = this
  new Watcher(vm, vm.$options.render, () => {}, true)
}

以上的代码终于牵扯到我们Watcher这个主角了,这里其实就是我们的渲染wather,这里的目的是通过Watcher来实现执行render函数,从而把数据插入到root节点里面去。下面看最简单的Watcher实现

let wid = 0
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm // 把vm挂载到当前的this上
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn // 把exprOrFn挂载到当前的this上,这里exprOrFn 等于 vm.$options.render
    }
    this.cb = cb // 把cb挂载到当前的this上
    this.options = options // 把options挂载到当前的this上
    this.id = wid++
    this.value = this.get() // 相当于运行 vm.$options.render()
  }
  get() {
    const vm = this.vm
    let value = this.getter.call(vm, vm) // 把this 指向到vm
    return value
  }
}

通过上面的一顿操作,终于在render中终于可以通过this.name 读取到data的数据了,也可以插入到root.innerHTML中去。阶段性的工作我们完成了。如下图,完成的首次渲染✌️

数据依赖收集和更新

首先数据收集,我们要有一个收集的地方,就是我们的Dep类,下面呢看看我们去怎么实现这个Dep

// 依赖收集
let dId = 0
class Dep{
  constructor() {
    this.id = dId++ // 每次实例化都生成一个id
    this.subs = [] // 让这个dep实例收集watcher
  }
  depend() {
    // Dep.target 就是当前的watcher
    if (Dep.target) {
      Dep.target.addDep(this) // 让watcher,去存放dep,然后里面dep存放对应的watcher,两个是多对多的关系
    }
  }
  notify() {
    // 触发更新
    this.subs.forEach(watcher => watcher.update())
  }
  addSub(watcher) {
    this.subs.push(watcher)
  }
}

let stack = []
// push当前watcher到stack 中,并记录当前watcer
function pushTarget(watcher) {
  Dep.target = watcher
  stack.push(watcher)
}
// 运行完之后清空当前的watcher
function popTarget() {
  stack.pop()
  Dep.target = stack[stack.length - 1]
}

Dep收集的类是实现了,但是我们怎么去收集了,就是我们数据观察的get里面实例化Dep然后让Dep收集当前的watcher。下面我们一步步来:

  • 1、在上面this.$mount()的代码中,我们运行了new Watcher(vm, vm.$options.render, () => {}, true),这时候我们就可以在Watcher里面执行this.get(),然后执行pushTarget(this),就可以执行这句话Dep.target = watcher,把当前的watcher挂载Dep.target上。下面看看我们怎么实现。
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
    this.cb = cb
    this.options = options
    this.id = wid++
    this.id = wId++
+    this.deps = []
+    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.get()
  }
  get() {
    const vm = this.vm
+   pushTarget(this)
    // 执行函数
    let value = this.getter.call(vm, vm)
+   popTarget()
    return value
  }
+  addDep(dep) {
+    let id = dep.id
+    if (!this.depsId.has(id)) {
+      this.depsId.add(id)
+      this.deps.push(dep)
+      dep.addSub(this);
+    }
+  }
+  update(){
+    this.get()
+  }
}
  • 2、知道Dep.target是怎么来之后,然后上面代码运行了this.get(),相当于运行了vm.$options.render,在render里面回执行this.name,这时候会触发Object.defineProperty·get方法,我们在里面就可以做些依赖收集(dep.depend)了,如下代码
function defineReactive(data, key, value) {
  let dep = new Dep()
  Object.defineProperty(data, key, {
    get() {
+      if (Dep.target) { // 如果取值时有watcher
+        dep.depend() // 让watcher保存dep,并且让dep 保存watcher,双向保存
+      }
      return value
    },
    set(newValue) {
      if (newValue == value) return
      observe(newValue) // 给新的值设置响应式
      value = newValue
+      dep.notify() // 通知渲染watcher去更新
    }
  })
  // 递归给数据设置get set
  observe(value);
}
  • 3、调用的dep.depend() 实际上是调用了 Dep.target.addDep(this), 此时Dep.target等于当前的watcher,然后就会执行
addDep(dep) {
  let id = dep.id
  if (!this.depsId.has(id)) {
    this.depsId.add(id)
    this.deps.push(dep) // 当前的watcher收集dep
    dep.addSub(this); // 当前的dep收集当前的watcer
  }
}

这里双向保存有点绕,大家可以好好去理解一下。下面我们看看收集后的des是怎么样子的。

  • 4、数据更新,调用this.name = '李四'的时候回触发Object.defineProperty.set方法,里面直接调用dep.notify(),然后循环调用所有的watcer.update方法更新所有watcher,例如:这里也就是重新执行vm.$options.render方法。

有了依赖收集个数据更新,我们也在index.html增加修改data属性的定时方法:

// index.html
<button onClick="changeData()">改变name和age</button>
// -----
// .....省略代码
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

运行效果如下图

到这里我们渲染watcher就全部实现了。

实现computed

首先我们在index.html里面配置一个computed,script标签的代码就如下:

const root = document.querySelector('#root')
var vue = new Vue({
  data() {
    return {
      name: '张三',
      age: 10
    }
  },
  computed: {
    info() {
      return this.name + this.age
    }
  },
  render() {
    root.innerHTML = `${this.name}----${this.age}----${this.info}`
  }
})
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

上面的代码,注意computed是在render里面使用了。

在vue.js中,之前写了下面这行代码。

if (options.computed) {
  // 初始化计算属性
  initComputed(vm)
}

我们现在就实现这个initComputed,代码如下

// 初始化computed
function initComputed(vm) {
  // 拿到computed配置
  const computed = vm.$options.computed
  // 给当前的vm挂载_computedWatchers属性,后面会用到
  const watchers = vm._computedWatchers = Object.create(null)
  // 循环computed每个属性
  for (const key in computed) {
    const userDef = computed[key]
    // 判断是函数还是对象
    const getter = typeof userDef === 'function' ? userDef : userDef.get
    // 给每一个computed创建一个computed watcher 注意{ lazy: true }
    // 然后挂载到vm._computedWatchers对象上
    watchers[key] = new Watcher(vm, getter, () => {}, { lazy: true })
    if (!(key in vm)) {
      defineComputed(vm, key, userDef)
    }
  }
}

大家都知道computed是有缓存的,所以创建watcher的时候,会传一个配置{ lazy: true },同时也可以区分这是computed watcher,然后到watcer里面接收到这个对象

class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
+    if (options) {
+      this.lazy = !!options.lazy // 为computed 设计的
+    } else {
+      this.lazy = false
+    }
+    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set()
+    this.value = this.lazy ? undefined : this.get()
  }
  // 省略很多代码
}

从上面这句this.value = this.lazy ? undefined : this.get()代码可以看到,computed创建watcher的时候是不会指向this.get的。只有在render函数里面有才执行。

现在在render函数通过this.info还不能读取到值,因为我们还没有挂载到vm上面,上面defineComputed(vm, key, userDef)这个函数功能就是让computed挂载到vm上面。下面我们实现一下。

// 设置comoputed的 set个set
function defineComputed(vm, key, userDef) {
  let getter = null
  // 判断是函数还是对象
  if (typeof userDef === 'function') {
    getter = createComputedGetter(key)
  } else {
    getter = userDef.get
  }
  Object.defineProperty(vm, key, {
    enumerable: true,
    configurable: true,
    get: getter,
    set: function() {} // 又偷懒,先不考虑set情况哈,自己去看源码实现一番也是可以的
  })
}
// 创建computed函数
function createComputedGetter(key) {
  return function computedGetter() {
    const watcher = this._computedWatchers[key]
    if (watcher) {
      if (watcher.dirty) {// 给computed的属性添加订阅watchers
        watcher.evaluate()
      }
      // 把渲染watcher 添加到属性的订阅里面去,这很关键
      if (Dep.target) {
        watcher.depend()
      }
      return watcher.value
    }
  }
}

上面代码有看到在watcher中调用了watcher.evaluate()watcher.depend(),然后去watcher里面实现这两个方法,下面直接看watcher的完整代码。

class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    }
    if (options) {
      this.lazy = !!options.lazy // 为computed 设计的
    } else {
      this.lazy = false
    }
    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.lazy ? undefined : this.get()
  }
  get() {
    const vm = this.vm
    pushTarget(this)
    // 执行函数
    let value = this.getter.call(vm, vm)
    popTarget()
    return value
  }
  addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
      this.depsId.add(id)
      this.deps.push(dep)
      dep.addSub(this);
    }
  }
  update(){
    if (this.lazy) {
      this.dirty = true
    } else {
      this.get()
    }
  }
  // 执行get,并且 this.dirty = false
+  evaluate() {
+    this.value = this.get()
+    this.dirty = false
+  }
  // 所有的属性收集当前的watcer
+  depend() {
+    let i = this.deps.length
+    while(i--) {
+      this.deps[i].depend()
+    }
+  }
}

代码都实现王完成之后,我们说下流程,

  • 1、首先在render函数里面会读取this.info,这个会触发createComputedGetter(key)中的computedGetter(key)
  • 2、然后会判断watcher.dirty,执行watcher.evaluate()
  • 3、进到watcher.evaluate(),才真想执行this.get方法,这时候会执行pushTarget(this)把当前的computed watcher push到stack里面去,并且把Dep.target 设置成当前的computed watcher`;
  • 4、然后运行this.getter.call(vm, vm) 相当于运行computedinfo: function() { return this.name + this.age },这个方法;
  • 5、info函数里面会读取到this.name,这时候就会触发数据响应式Object.defineProperty.get的方法,这里name会进行依赖收集,把watcer收集到对应的dep上面;并且返回name = '张三'的值,age收集同理;
  • 6、依赖收集完毕之后执行popTarget(),把当前的computed watcher从栈清除,返回计算后的值('张三+10'),并且this.dirty = false
  • 7、watcher.evaluate()执行完毕之后,就会判断Dep.target 是不是true,如果有就代表还有渲染watcher,就执行watcher.depend(),然后让watcher里面的deps都收集渲染watcher,这就是双向保存的优势。
  • 8、此时name都收集了computed watcher渲染watcher。那么设置name的时候都会去更新执行watcher.update()
  • 9、如果是computed watcher的话不会重新执行一遍只会把this.dirty 设置成 true,如果数据变化的时候再执行watcher.evaluate()进行info更新,没有变化的的话this.dirty 就是false,不会执行info方法。这就是computed缓存机制。

实现了之后我们看看实现效果:

这里conputed的对象set配置没有实现,大家可以自己看看源码

watch实现

先在script标签配置watch配置如下代码:

const root = document.querySelector('#root')
var vue = new Vue({
  data() {
    return {
      name: '张三',
      age: 10
    }
  },
  computed: {
    info() {
      return this.name + this.age
    }
  },
  watch: {
    name(oldValue, newValue) {
      console.log(oldValue, newValue)
    }
  },
  render() {
    root.innerHTML = `${this.name}----${this.age}----${this.info}`
  }
})
function changeData() {
  vue.name = '李四'
  vue.age = 20
}

知道了computed实现之后,自定义watch实现很简单,下面直接实现initWatch

function initWatch(vm) {
  let watch = vm.$options.watch
  for (let key in watch) {
    const handler = watch[key]
    new Watcher(vm, key, handler, { user: true })
  }
}

然后修改一下Watcher,直接看Wacher的完整代码。

let wId = 0
class Watcher {
  constructor(vm, exprOrFn, cb, options) {
    this.vm = vm
    if (typeof exprOrFn === 'function') {
      this.getter = exprOrFn
    } else {
+      this.getter = parsePath(exprOrFn) // user watcher 
    }
    if (options) {
      this.lazy = !!options.lazy // 为computed 设计的
+      this.user = !!options.user // 为user wather设计的
    } else {
+      this.user = this.lazy = false
    }
    this.dirty = this.lazy
    this.cb = cb
    this.options = options
    this.id = wId++
    this.deps = []
    this.depsId = new Set() // dep 已经收集过相同的watcher 就不要重复收集了
    this.value = this.lazy ? undefined : this.get()
  }
  get() {
    const vm = this.vm
    pushTarget(this)
    // 执行函数
    let value = this.getter.call(vm, vm)
    popTarget()
    return value
  }
  addDep(dep) {
    let id = dep.id
    if (!this.depsId.has(id)) {
      this.depsId.add(id)
      this.deps.push(dep)
      dep.addSub(this);
    }
  }
  update(){
    if (this.lazy) {
      this.dirty = true
    } else {
+      this.run()
    }
  }
  // 执行get,并且 this.dirty = false
  evaluate() {
    this.value = this.get()
    this.dirty = false
  }
  // 所有的属性收集当前的watcer
  depend() {
    let i = this.deps.length
    while(i--) {
      this.deps[i].depend()
    }
  }
+  run () {
+    const value = this.get()
+    const oldValue = this.value
+    this.value = value
    // 执行cb
+    if (this.user) {
+      try{
+        this.cb.call(this.vm, value, oldValue)
+      } catch(error) {
+        console.error(error)
+      }
+    } else {
+      this.cb && this.cb.call(this.vm, oldValue, value)
+    }
+  }
}
function parsePath (path) {
  const segments = path.split('.')
  return function (obj) {
    for (let i = 0; i < segments.length; i++) {
      if (!obj) return
      obj = obj[segments[i]]
    }
    return obj
  }
}

最后看看效果

当然很多配置没有实现,比如说options.immediate 或者options.deep等配置都没有实现。篇幅太长了。自己也懒~~~

博客文章地址:https://blog.naice.me

查看原文

赞 14 收藏 13 评论 2

naice 提出了问题 · 4月20日

解决「css」这种滚动列表渐变消息是怎么做到的?

如下图??用到是什么css技术呀???

image.png

求大佬解答??

关注 3 回答 2

naice 收藏了文章 · 2019-11-10

TypeScript 基础精粹

原文地址地址:TypeScript 基础精粹

基础笔记的github地址:https://github.com/qiqihaobenben/Front-End-Basics ,可以watch,也可以star。


类型注意事项

数组类型

有两种类型注解方式,特别注意第二种使用 TS 内置的 Array 泛型接口。

let arr1: number[] = [1,2,3]
// 下面就是使用 TS 内置的 Array 泛型接口来实现的
let arr2: Array<number | string> = [1,2,3,"abc"]

元组类型

元组是一种特殊的数组,限定了数组元素的个数和类型

let tuple: [number, string] = [0, "1"];

需要注意元组的越界问题,虽然可以越界添加元素,但是仍然是不能越界访问,强烈不建议这么使用

tuple.push(2)  // 不报错
console.log(tuple) // [0, "1", 2] 也能都打印出来
console.log(tuple[2]) // 但是想取出元组中的越界元素,就会报错元组长度是2,在index为2时没有元素

函数类型

函数类型可以先定义再使用,具体实现时就可以不用注明参数和返回值类型了,而且参数名称也不用必须跟定义时相同。

let compute: (x: number, y: number) => number;
compute = (a, b) => a + b;

对象类型

对象如果要赋值或者修改属性值,那么就不能用简单的对象类型,需要定义完整的对象类型

let obj: object = { x: 1, y: 2 };
obj.x = 3; // 会报错,只是简单的定义了是object类型,但是里面到底有什么属性没有标明

// 需要改成如下的对象类型定义
let obj: { x: number; y: number } = { x: 1, y: 2 };
obj.x = 3;

symbol 类型

symbol 类型可以直接声明为 symbol 类型,也可以直接赋值,跟 ES6 一样,两个分别声明的 symbol 是不相等的。

let s1: symbol = Symbol();
let s2 = Symbol();
console.log(s1 === s2)  // false

undefined 、null 类型

变量可以被声明为 undefined 和 null ,但是一旦被声明,就不能再赋值其他类型。

let un: undefined = undefined;
let nu: null = null;
un = 1 // 会报错
nu = 1 // 会报错

undefined 和 null 是任何类型的子类型,那就可以赋值给其他类型。但是需要设置配置项 "strictNullChecks": false

// 设置 "strictNullChecks": false
let num: number = 123;
num = undefined;
num = null;

// 但是更建议将 num 设置为联合类型
let num: number | undefined | null = 123;
num = undefined;
num = null;

枚举类型

枚举分为数字枚举和字符串枚举,此外还有异构枚举(不推荐)

数字枚举

枚举既能通过名字取值,又能通过索引取值,我们具体看一下是怎么取到的。

enum Role {
  Reporter = 1,
  Developer,
  Maintainer,
  Owner,
  Guest
}
Role.Reporter = 2 // 枚举成员是只读的,不能修改重新赋值

console.log(Role)
//打印出来:{1: "Reporter", 2: "Developer", 3: "Maintainer", 4: "Owner", 5: "Guest", Reporter: 1, Developer: 2, Maintainer: 3, Owner: 4, Guest: 5}
//我们看到打印出来是一个对象,对象中有索引值作为 key 的,有名字作为 key 的,所以枚举既能通过名字取值,又能通过索引取值

// 看一下 TS 编译器是怎么用反向映射实现枚举的。
"use strict";
var Role;
(function (Role) {
    Role[Role["Reporter"] = 1] = "Reporter";
    Role[Role["Developer"] = 2] = "Developer";
    Role[Role["Maintainer"] = 3] = "Maintainer";
    Role[Role["Owner"] = 4] = "Owner";
    Role[Role["Guest"] = 5] = "Guest";
})(Role || (Role = {}));

字符串枚举

字符串枚举只能通过名字取值,不能通过索引取值。

enum Message {
  Success = '成功',
  Fail = '失败'
}
console.log(Message)
// 打印出来:{Success: "成功", Fail: "失败"}
// 我们看到只有名字作为 key ,说明字符串枚举不能反向映射

常量枚举

用 const 声明的枚举就是常量枚举,会在编译阶段被移除。如下代码编译后 Month 是不产生代码的,只能在编译前使用,当我们不需要一个对象,但是需要一个对象的值的时候,就可以使用常量枚举,这样可以减少编译后的代码。

const enum Month {
  Jan,
  Feb,
  Mar
}
let month = [Month.Jan, Month.Feb, Month.Mar];

异构枚举

数字和字符串枚举混用,不推荐

enum Answer {
  N,
  Y = 'Yes',
  // C, // 在字符串枚举成员后面的枚举成员必须赋一个初始值
  //  X = Math.random() // 含字符串成员的枚举中不允许使用计算值
}

枚举成员注意点

  • 枚举成员是只读的,不能修改重新赋值
  • 枚举成员的分为 const member 和 computer member

    • 常量成员(const member),包括没有初始值的情况、对已有枚举成员的引用、常量表达式,会在编译的时候计算出结果,以常量的形式出现在运行时环境
    • 计算成员(computer member),需要被计算的枚举成员,不会在编译阶段进行计算,会被保留到程序的执行阶段
  • 在 computed member 后面的枚举成员,一定要赋一个初始值,否则报错
  • 含字符串成员的枚举中不允许使用计算值(computer member),并且在字符串枚举成员后面的枚举成员必须赋一个初始值,否则会报错(见上面的异构类型)
  • 数字枚举中,如果有两个成员有同样索引,那么后面索引会覆盖前面的(见下面的枚举 number )

     // 枚举成员
     enum Char {
       // const member 常量枚举,会在编译阶段计算结果,以常量的形式出现在运行时环境
       a,
       b = Char.a,
       c = 1 + 3,
    
       // computed member 需要被计算的枚举成员,不会在编译阶段进行计算,会被保留到执行阶段
       d = Math.random(),
       e = '123'.length,
       // 在 computed member 后面的枚举成员,一定要赋一个初始值,否则报错
       f = 1
     }
     console.log(Char)
    
     // 枚举 number
     enum number { a = 1, b = 5, c = 4, d }
     console.log(number) //打印出{1: "a", 4: "c", 5: "d", a: 1, b: 5, c: 4, d: 5}
     // b赋初始值为5,c赋初始值为4,按照索引递增,d的索引就是5,索引相同时,后面的值覆盖前面的,所以5对应的 value 就是d

    枚举和枚举成员作为单独的类型

    有以下三种情况,(1)枚举成员都没有初始值、(2)枚举成员都是数字枚举、(3)枚举成员都是字符串枚举

    • 变量定义为数字枚举类型,赋值任意 number 类型的值都是可以的(可以超出枚举定义的数字范围),对枚举没有影响,但是不能赋值字符串等。
    • 不同的枚举类型是不能比较的,不过同一个枚举类型是可以比较的,但是同一个枚举类型的不同枚举成员是不能比较的
    • 变量定义为枚举类型,甚至就算定义为枚举类型的某个具体成员的类型,赋值也是对枚举没有影响的。(如下,E和F的结果还是不变的)
    • 字符串枚举类型的赋值,只能用枚举成员,不能随意赋值。(如果下F)
     enum E { a, b } // 枚举成员都没有初始值
     enum F { a = 1, b = 5, c = 4, d } // 枚举成员都是数字枚举
     enum G { a = 'apple', b = 'banana' } // 枚举成员都是字符串枚举
    
     // 变量定义为数字枚举类型,赋值任意number类型的值都是可以的,对枚举没有影响,但是不能赋值字符串等。
     let e: E = 3
     let f: F = 3
     // e === f // 不同的枚举类型是不能比较的,会报错
     console.log(E,F,e,f) // 打印:{0: "a", 1: "b", a: 0, b: 1}, {1: "a", 4: "c", 5: "d", a: 1, b: 5, c: 4, d: 5}, 3, 3
     // 可见变量定义为E,F赋值,对E,F枚举本身没有影响
    
     let e1: E = 3
     let e2: E = 3
     console.log(e1 === e2) // 同一个枚举类型是可以比较的,结果为true
    
     let e3: E.a = 3
     let e4: E.b = 3
     // e3 === e4 // 同一个枚举类型的不同枚举成员是不能比较的,会报错
     console.log(E,E.a,E.b,e3,e4) // 打印:{0: "a", 1: "b", a: 0, b: 1} 0 1 3 3 ,可见变量定义为E.a,E.b赋值,对E以及E.a,E.b枚举本身没有影响
    
     //字符串枚举类型的赋值,只能用枚举成员,不能随意赋值。
     let g1: G = 'abc' // 会报错
     let g2: G = G.a // g2能赋值G.a或者G.b
     let g3: G.a = G.a // g2 只能赋值G.a

接口类型

接口约束对象、函数、类的结构

对象类型接口

对象冗余字段

对象类型接口直接验证有冗余字段的对象字面量时会报错,这种冗余字段有时是不可避免的存在的。

 interface List {
   id: number;
   name: string;
 }
 interface Result {
   data: List[];
 }

 function render(result: Result) {
   result.data.forEach((value) => {
     console.log(value.id,value.name)
   })
 }

 render({
   data: [
     {id: 1, name: 'A',sex: 'male'},
     {id: 2,name: 'B'}
   ]
 });
 // 这就是对象类型接口直接验证有冗余字段的“对象字面量”,上面render中会有报错,说对象只能指定已知属性,并且"sex"不在类型"List"中
解决方法一:在外面声明变量 result ,然后把 result 传入 render 函数,避免传入对象字面量。
// 把字面量先赋值给一个变量这样就能绕过检测
 let result = {
   data: [
     {id: 1, name: 'A',sex: 'male'},
     {id: 2,name: 'B'}
   ]
 }
 render(result);
解决方法二: 用类型断言(两种 as 和尖括号),但是如果对象字面中都没有符合的,还是会报错,可以用 as unknown as xxx
 render({
   data: [{ id: 1, name: "A", sex: "male" }, { id: 2, name: "B" }]
 } as Result);

 // 但是如果传入的对象字面量中没有一项是符合的,那用类型断言还是会报错
 render({
   data: [{ id: 1, name: "A", sex: "male" }]
 } as Result); // 还是会报错属性"data"的类型不兼容

 // 现在就需要这么写,用 as unknown as xxx
 render({
   data: [{ id: 1, name: "A", sex: "male" }]
 } as unknown as Result);
解决方法三:用字符串索引签名
 interface List {
   id: number;
   name: string;
   [x:string]: any;
 }
 // 这样对象字面量就可以包含任意多个字符串属性了。
接口属性可定义为只读属性和可选属性
 interface List {
   readonly id: number; // 只读属性
   name: string;
   age?: number; // 可选属性
 }

##### 可索引类型
不确定一个接口中有多少属性时,可以使用可索引类型。分为数字索引签名和字符串索引签名,如果接口定义了某一种索引签名的值的类型,之后再定义的属性的值必须是签名值的类型的子类型。可以同时使用两种类型的索引,但是数字索引的返回值必须是字符串索引返回值类型的子类型。

 interface Names {
   [x: string]: number | string;
   // y: boolean; // 会报错 boolean 不会赋值给字符串索引类型,因为字符串索引签名的类型是 number | string,所以之后再定义的属性必须是签名值类型的子类型
   [z: number]: number; // 字符串索引签名后也能定义数字索引签名,数字索引的返回值必须是字符串索引返回值类型的子类型
 }

函数类型接口

interface Add {
  (x: number, y: number): number;
}
// 跟变量声明是等价的:let Add: (a: number, b: number) => number
let add4: Add = (a,b) => a + b

混合接口

混合接口,需要注意看一下,接口中的属性没有顺序之分,混合接口不需要第一个属性是匿名函数。

interface Lib {
  version: string;
  ():void;
  doSomething():void;
}
// 需要用到类型断言
let lib: Lib = (() => {}) as Lib;
lib.version = '1.0'
lib.doSomething = () => {}

接口继承

// 以下是接口继承的例子
interface Human {
  name: string;
  eat(): void;
}
interface Man extends Human {
  run(): void
}
interface Child {
  cry():void
}

interface Boy extends Man, Child {}
let boy: Boy = {
  name: '',
  run(){},
  eat(){},
  cry(){}
}

函数类型相关

定义 TS 函数的四种方式,第一种方式可以直接调用,但是后三种就需要先实现定义的函数再调用

// 第一种,直接声明
function add1 (x:number, y:number):number {
  return x + y
}
// 应用时形参和实参一一对应
add1(1, 2)

// 第二种 变量声明
let add2: (x:number, y:number) => number
// 应用如下
add2  = (a, b) => a + b
add2(2, 2)

// 第三种 类型别名
type Add3 = (x: number, y: number) => number
// 应用如下
let add3: Add3 = (a, b) => a + b
add3(3, 2)


// 第四种 接口实现
interface Add4 {
  (x: number, y: number): number;
}
// 跟变量声明是等价的:let Add4: (a: number, b: number) => number
let add4: Add4 = (a,b) => a + b
add4(4, 2)

可选参数

可选参数必须位于必选参数之后,即可选参数后面不能再有必选参数

// y后面不能再有必选参数,所以d会报错
// function add5(x:number, y?:number, d:number) {

// 正确如下
function add5(x:number, y?:number) {
  return y? y + x: x
}
add5(1)

参数默认值

带默认值的参数不需要放在必选参数后面,但如果带默认值的参数出现在必选参数前面,必须明确的传入 undefined 值来获得默认值。在所有必选参数后面的带默认值的参数都是可选的,与可选参数一样,在调用函数的时候可以省略。

function add6 (x: number, y = 0, z:number,q = 1) {
  return x +y + z +q
}
// 第二个参数必须传入undefined占位
add6(1,undefined,2)

函数重载

要求定义一系列的函数声明,在类型最宽泛的版本中实现重载, TS 编译器的函数重载会去查询一个重载的列表,并且从最开始的一个进行匹配,如果匹配成功,就直接执行。所以我们要把大概率匹配的定义写在前面。

函数重载的声明只用于类型检查阶段,在编译后会被删除。

function add8(...rest: number[]):number
function add8(...rest: string[]):string
function add8(...rest: any[]):any {
  let first = rest[0]
  if(typeof first === 'string') {
    return rest.join('')
  }
  if(typeof first === 'number') {
    return rest.reduce((pre,cur) => pre + cur)
  }
}
add8(1,2,3) // 6
add8('1','2','3') // '123'

类属性和方法注意点

  • 类属性都是实例属性,不是原型属性,而类方法都是原型方法
  • 实例的属性必须具有初始值,或者在构造函数中初始化,除了类型为any的。

类的继承

派生类的构造函数必须包含“super”调用,并且访问派生类的构造函数中的this之前,必须调用“super"

类修饰符

1、public: 所有人可见(默认)。

2、 private: 私有属性

私有属性只能在声明的类中访问,在子类或者生成的实例中都不能访问,但是 private 属性可以在实例的方法中被访问到,因为也相当于在类中访问,但是子类的的实例方法肯定是访问不到的。

可以把类的 constructor 定义为私有类型,那么这个类既不能被实例化也不能被继承

3、 protected 受保护属性

受保护属性只能在声明的类及其子类中访问,但是 protected 属性可以在实例的方法中被访问到,因为也相当于在类中访问

可以把类的 constructor 定义为受保护类型,那么这个类不能被实例化,但是可以被继承,相当于基类

4、 readonly 只读属性

只读属性必须具有初始值,或者在构造函数中初始化,初始化后就不能更改了,并且已经设置过初始值的只读属性,也是可以在构造函数中被重新初始化的。但是在其子类的构造函数中不能被重新初始化。

5、 static 静态属性

只能通过类的名称调用,不能在实例和构造函数或者子类中的构造函数和实例中访问,但是静态属性是可以继承的,用子类的类名可以访问

注意:构造函数的参数也可以添加修饰符,这样可以将参数直接定义为类的属性

class Dog {
  constructor(name: string) {
    this.name = name
    this.legs = 4 // 已经有默认值的只读属性是可以被重新初始化的
  }
  public name: string
  run() { }
  private pri() { }
  protected pro() { }
  readonly legs: number = 3
  static food: string = 'bones'
}
let dog = new Dog('jinmao')
// dog.pri() // 私有属性不能在实例中调用
// dog.pro() // 受保护的属性,不能在实例中调用
console.log(Dog.food) // 'bones'


class Husky extends Dog {
  constructor(name: string, public color: string) {
    super(name)
    this.color = color
    // this.legs = 5 // 子类的构造函数中是不能对父类的只读属性重新初始化的
    // this.pri() // 子类不能调用父类的私有属性
    this.pro() // 子类可以调用父类的受保护属性
  }
  protected age: number = 3
  private nickname: string = '二哈'
  info(): string {
    return this.age + this.nickname
  }
  // color: string // 参数用了修饰符,可以直接定义为属性,这里就不需要了
}

let husky = new Husky('husky', 'black')
husky.info() // 如果调用的类的方法中有对类的私有属性和受保护属性的访问,这是不报错的。
console.log(Husky.food) // 'bones' 子类可以调用父类的静态属性

抽象类

只能被继承,不能被实例化的类。

在抽象类中可以添加共有的方法,也可以添加抽象方法,然后由子类具体实现

abstract class Animal {
  eat() {
    console.log('eat')
  }
  abstract sleep(): void // 抽象方法,在子类中实现
}
// let animal = new Animal() // 会报错,抽象类无法创建实例


class Cat extends Animal {
  constructor(public name: string) {
    super()
  }
  run() { }
  // 必须实现抽象方法
  sleep() {
    console.log('sleep')
  }
}

let cat = new Cat('jiafei')
cat.eat()

接口类

  • 类实现接口时,必须实现接口的全部属性,不过类可以定义自己的属性
  • 接口不能约束类的构造函数,只能约束公有成员
interface Human {
  // new (name:string):void // 接口不能约束类的构造函数
  name: string;
  eat(): void;
}

class Asian implements Human {
  constructor (name: string) {
    this.name = name
  }
  name: string
  // private name: string  // 实现接口时用了私有属性会报错
  eat() {}
  sleep(){}
}

接口继承类

相当于把类的成员抽象出来,只有类的成员结构,但是没有具体实现

接口抽离类成员时不仅抽离了公有属性,还抽离了私有属性和受保护属性,所以非继承的子类都会报错

被抽象的类的子类,也可以实现类抽象出来的接口,而且不用实现这个子类的父类已有的属性

class Auto {
  state = 1
  // protected state2 = 0 // 下面的C会报错,因为C并不是 Auto 的子类,C只是实现了 Auto 抽象出来的接口
}
interface AutoInterface extends Auto {

}
class C implements AutoInterface {
  state = 1
}

// 被抽象的类的子类,也可以实现类抽象出来的接口,而且不用实现父类的已有的属性
class Bus extends Auto implements AutoInterface {
  // 不用设置 state ,Bus 的父类已经有了。

}

泛型

泛型函数

注意:用泛型定义函数类型时的位置不用,决定是否需要指定参数类型,见下面例子。

泛型函数例子

function log<T>(value: T): T {
  console.log(value)
  return value
}

log<string[]>(['a', 'b'])
log([1, 2]) // 可以不用指定类型,TS会自动推断

// 还可以用类型别名定义泛型函数
//下面的定义不用指定参数类型
type Log = <T>(value:T) => T // 不用指定参数类型,会自己推断
let myLog: Log = log
//下面的定义必须指定参数类型
type Log<T> = (value:T) => T // 如果这样用泛型定义函数类型,必须指定一个参数类型
let myLog: Log<string> = log

泛型接口

function log<T>(value: T): T {
  console.log(value)
  return value
}

// 以下仅约束泛型接口中的一个泛型函数,实现不用指定泛型的参数类型
interface Log {
  <T>(value: T): T;
}
let myLog: Log = log

// 以下约束整个泛型接口,实现需要指定泛型的参数类型,或者用带默认类型的泛型
interface Log1<T> {
  (value: T): T;
}
let myLog1: Log1<string> = log

interface Log2<T = string> {
  (value: T): T
}
let myLog2: Log2 = log

注意:泛型接口的泛型定义为全局时,实现必须指定一个参数类型,或者用带默认类型的泛型

泛型类

class Log3<T> {
  // 静态成员不能引用类的泛型参数
  // static start(value: T) {
  //   console.log(value)
  // }
  run(value: T) {
    console.log(value)
    return value
  }
}
let log3 = new Log3<number>()
log3.run(1)

//不指定类型,就可以传入任何类型
let log4 = new Log3()
log4.run('abc')

注意:泛型不能应用于类的静态成员。并且实例化时,不指定类型,就可以传入任何类型

泛型约束

约束泛型传入的类型

interface Length {
  length: number
}
function log5<T extends Length>(value: T) {
  // 想要打印出定义为泛型T的value的length属性,则T必须要有length属性,所以需要泛型约束,T继承length接口后,就肯定具有了length属性
  console.log(value,value.length)
  return value
}
log5([1])
log5('abc')
log5({length: 1})

泛型总结

  • 利用泛型,函数和类可以轻松地支持多种类型,增强程序的扩展性
  • 不必写多条函数重载,冗长的联合类型声明,增强代码可读性
  • 灵活控制类型之间的约束

类型检查机制

类型检查机制: TypeScript 编译器在做类型检查时,所秉承的一些原则,以及表现出的一些行为。其作用是辅助开发,提高开发效率

类型推断

类型推断: 指的是不需要指定变量的类型(函数的返回值类型),TypeScript 可以根据某些规则自动地为其推断出一个类型

基础类型推断

let a = 1 // 推断为 number
let b = [1] // 推断为 number[]
let c = (x = 1) => x + 1 // 推断为 (x?: number) => number

最佳通用类型推断

当需要从多个类型中推断出一个类型的时候,TypeScript 会尽可能的推断出一个兼容当前所有类型的通用类型

let d = [1, null]
// 推断为一个最兼容的类型,所以推断为(number | null)[]
// 当关闭"strictNullChecks"配置项时,null是number的子类型,所以推断为number[]

上下文类型推断

以上的推断都是从右向左,即根据表达式推断,上下文类型推断是从左向右,通常会发生在事件处理中。

类型断言

在确定自己比 TS 更准确的知道类型时,可以使用类型断言来绕过 TS 的检查,改造旧代码很有效,但是防止滥用。

interface Bar {
  bar: number
}
let foo = {} as Bar
foo.bar = 1

// 但是推荐变量声明时就要指定类型
let foo1: Bar = {
  bar: 1
}

类型兼容

当一个类型Y可以被赋值给另一个类型X时,我们就可以说类型X兼容类型Y

X兼容Y:X(目标类型) = Y(源类型)

let s: string = 'a'
s = null // 把编译配置中的strictNullChecks设置成false,字符类型是兼容null类型的(因为null是字符的子类型)

接口兼容

成员少的兼容成员多的

interface X {
  a: any;
  b: any;
}
interface Y {
  a: any;
  b: any;
  c: any;
}

let x: X = { a: 1, b: 2 }
let y: Y = { a: 1, b: 2, c: 3 }
// 源类型只要具有目标类型的必要属性,就可以进行赋值。接口之间相互兼容,成员少的兼容成员多的。
x = y
// y = x // 不兼容

函数兼容性

type Handler = (a: number, b: number) => void
function test(handler: Handler) {
  return handler
}
1、参数个数
固定参数

目标函数的参数个数一定要多于源函数的参数个数

Handler 目标函数,传入 test 的 参数函数 就是源函数

let handler1 = (a: number) => { }
test(handler1) // 传入的函数能接收一个参数,且参数是number,是兼容的
let handler2 = (a: number, b: number, c: number) => { }
test(handler2) // 会报错 传入的函数能接收三个参数(参数多了),且参数是number,是不兼容的
可选参数和剩余参数
let a1 = (p1: number, p2: number) => { }
let b1 = (p1?: number, p2?: number) => { }
let c1 = (...args: number[]) => { }
(1) 固定参数是可以兼容可选参数和剩余参数的
a1 = b1 // 兼容
a1 = c1 // 兼容
(2) 可选参数是不兼容固定参数和剩余参数的,但是可以通过设置"strictFunctionTypes": false来消除报错,实现兼容
b1 = a1 //不兼容
b1 = c1 // 不兼容
(3) 剩余参数可以兼容固定参数和可选参数
c1 = a1 // 兼容
c1 = b1 // 兼容
2、参数类型
基础类型
// 接上面的test函数
let handler3 = (a: string) => { }
test(handler3) // 类型不兼容
接口类型

接口成员多的兼容成员少的,也可以理解把接口展开,参数多的兼容参数少的。对于不兼容的,也可以通过设置"strictFunctionTypes": false来消除报错,实现兼容

interface Point3D {
  x: number;
  y: number;
  z: number;
}
interface Point2D {
  x: number;
  y: number;
}
let p3d = (point: Point3D) => { }
let p2d = (point: Point2D) => { }

p3d = p2d // 兼容
p2d = p3d // 不兼容
3、返回值类型

目标函数的返回值类型必须与源函数的返回值类型相同,或者是其子类型

let f = () => ({ name: 'Alice' })
let g = () => ({ name: 'A', location: 'beijing' })
f = g // 兼容
g = f // 不兼容
4、函数重载

函数重载列表(目标函数)

function overload(a: number, b: number): number;
function overload(a: string, b: string): string;

函数的具体实现(源函数)

function overload(a: any, b: any): any { }

目标函数的参数要多于源函数的参数才能兼容

function overload(a:any,b:any,c:any):any {} // 具体实现时的参数多于重载列表中匹配到的第一个定义的函数的参数,也就是源函数的参数多于目标函数的参数,不兼容

返回值类型不兼容

function overload(a:any,b:any) {} // 去掉了返回值的any,不兼容

枚举类型兼容性

enum Fruit { Apple, Banana }
enum Color { Red, Yello }
枚举类型和数字类型是完全兼容的
let fruit: Fruit.Apple = 4
let no: number = Fruit.Apple
枚举类型之间是完全不兼容的
let color: Color.Red = Fruit.Apple // 不兼容

类的兼容性

和接口比较相似,只比较结构,需要注意,在比较两个类是否兼容时,静态成员和构造函数是不参与比较的,如果两个类具有相同的实例成员,那么他们的实例就相互兼容

class A {
  constructor(p: number, q: number) { }
  id: number = 1
}
class B {
  static s = 1
  constructor(p: number) { }
  id: number = 2
}
let aa = new A(1, 2)
let bb = new B(1)
// 两个实例完全兼容,静态成员和构造函数是不比较的
aa = bb
bb = aa
私有属性

类中存在私有属性情况有两种,如果其中一个类有私有属性,另一个没有。没有的可以兼容有的,如果两个类都有,那两个类都不兼容。

如果一个类中有私有属性,另一个类继承了这个类,那么这两个类就是兼容的。

class A {
  constructor(p: number, q: number) { }
  id: number = 1
  private name:string = '' // 只在A类中加这个私有属性,aa不兼容bb,但是bb兼容aa,如果A、B两个类中都加了私有属性,那么都不兼容
}
class B {
  static s = 1
  constructor(p: number) { }
  id: number = 2
}
let aa = new A(1, 2)
let bb = new B(1)
aa = bb // 不兼容
bb = aa // 兼容


// A中有私有属性,C继承A后,aa和cc是相互兼容的
class C extends A { }
let cc = new C(1, 2)
// 两个类的实例是兼容的
aa = cc
cc = aa

泛型兼容

泛型接口

泛型接口为空时,泛型指定不同的类型,也是兼容的。

interface Empty<T> {}

let obj1:Empty<number> = {}
let obj2:Empty<string> = {}
// 兼容
obj1 = obj2
obj2 = obj1

如果泛型接口中有一个接口成员时,类型不同就不兼容了

interface Empty<T> {
  value: T
}

let obj1:Empty<number> = {}
let obj2:Empty<string> = {}
// 报错,都不兼容
obj1 = obj2
obj2 = obj1
泛型函数

两个泛型函数如果定义相同,没有指定类型参数的话也是相互兼容的

let log1 = <T>(x: T): T => {
  return x
}
let log2 = <U>(y: U): U => {
  return y
}
log1 = log2
log2 = log1

兼容性总结

  • 结构之间兼容:成员少的兼容成员多的
  • 函数之间兼容:参数多的兼容参数少的

类型保护机制

指的是 TypeScript 能够在特定的区块(类型保护区块)中保证变量属于某种特定的类型。可以在此区块中放心地引用此类型的属性,或者调用此类型的方法。

前置代码,之后的代码在此基础运行

enum Type { Strong, Week }

class Java {
  helloJava() {
    console.log('hello Java')
  }
  java: any
}

class JavaScript {
  helloJavaScript() {
    console.log('hello JavaScript')
  }
  javaScript: any
}

实现 getLanguage 方法直接用 lang.helloJava 是不是存在作为判断是会报错的

function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  // 如果想根据lang实例的类型,直接用lang.helloJava是不是存在来作为判断是会报错的,因为现在lang是Java和JavaScript这两种类型的联合类型
  if (lang.helloJava) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }
  return lang
}
利用之前的知识可以使用类型断言解决
function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  // 这里就需要用类型断言来告诉TS当前lang实例要是什么类型的
  if ((lang as Java).helloJava) {
    (lang as Java).helloJava()
  } else {
    (lang as JavaScript).helloJavaScript()
  }
  return lang
}
类型保护第一种方法,instanceof
function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  // instanceof 可以判断实例是属于哪个类,这样TS就能判断了。
  if (lang instanceof Java) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }
  return lang
}
类型保护第二种方法, in 可以判断某个属性是不是属于某个对象
function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  //  in 可以判断某个属性是不是属于某个对象 如上helloJava和java都能判断出来
  if ('java' in lang) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }
  return lang
}
类型保护第三种方法, typeof 类型保护,可以帮助我们判断基本类型
function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  // x也是联合类型,typeof类型保护,可以判断出基本类型。
  if (typeof x === 'string') {
    x.length
  } else {
    x.toFixed(2)
  }
  return lang
}
类型保护第四种方法,通过创建一个类型保护函数来判断对象的类型

类型保护函数的返回值有点不同,用到了 is ,叫做类型谓词

function isJava(lang: Java | JavaScript): lang is Java {
  return (lang as Java).helloJava !== undefined
}
function getLanguage(type: Type, x: string | number) {
  let lang = type === Type.Strong ? new Java() : new JavaScript()

  // 通过创建一个类型保护函数来判断对象的类型
  if (isJava(lang)) {
    lang.helloJava()
  } else {
    lang.helloJavaScript()
  }
  return lang
}

总结

不同的判断方法有不同的使用场景:

  • typeof:判断一个变量的类型(多用于基本类型)
  • instanceof:判断一个实例是否属于某个类
  • in:判断一个属性是否属于某个对象
  • 类型保护函数:某些判断可能不是一条语句能够搞定的,需要更多复杂的逻辑,适合封装到一个函数内

高级类型

交叉类型

& 符号。虽然叫交叉类型,但是是取的所有类型的并集

interface DogInterface {
  run(): void
}

interface CatInterface {
  jump(): void
}

// 交叉类型 用 & 符号。虽然叫交叉类型,但是是取的所有类型的并集。
let pet: DogInterface & CatInterface = {
  run() { },
  jump() { }
}

联合类型

声明的类型并不确定,可以为多个类型中的一个,除了可以是 TS 中规定的类型外,还有字符串字面量联合类型、数字字面量联合类型

let a: number | string = 1;

// 字符串字面量联合类型
let b: 'a' | 'b' | 'c' = 'a'

// 数字字面量联合类型
let c: 1 | 2 | 3 = 1

对象联合类型

对象的联合类型,只能取两者共有的属性,所以说对象联合类型只能访问所有类型的交集

// 接上文DogInterface和CatInterface
class Dog implements DogInterface {
  run() { }
  eat() { }
}

class Cat implements CatInterface {
  jump() { }
  eat() { }
}
enum Master { Boy, Girl }
function getPet(master: Master) {
  // pet为Dog和Cat的联合类型,只能取两者共有的属性,所以说联合类型在此时只能访问所有类型的交集
  let pet = master === Master.Boy ? new Dog() : new Cat()
  pet.eat()
  // pet.run() // 不能访问,会报错
  return pet
}

可区分的联合类型

这种模式是结合了联合类型和字面量类型的类型保护方法,一个类型如果是多个类型的联合类型,并且每个类型之间有一个公共的属性,我们就可以凭借这个公共属性来创建不同的类型保护区块。

核心是利用两种或多种类型的共有属性,来创建不同的代码保护区块

下面的函数如果只有 Square 和 Rectangle 这两种联合类型,没有问题,但是一旦扩展增加 Circle 类型,类型校验就不会正常运行,而且也不报错,这个时候我们是希望代码有报错提醒的。

interface Square {
  kind: "square";
  size: number;
}
interface Rectangle {
  kind: 'rectangle';
  width: number;
  height: number;
}
interface Circle {
  kind: 'circle';
  r: number;
}
type Shape = Square | Rectangle | Circle

// 下面的函数如果只有Square和Rectangle这两种联合类型,没有问题,但是一旦扩展增加Circle类型,不会正常运行,而且也不报错,这个时候我们是希望代码有报错提醒的。
function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
      break;
    case "rectangle":
      return s.width * s.height;
      break;
  }
}

console.log(area({ kind: 'circle', r: 1 }))
// undefined,不报错,这个时候我们是希望代码有报错提醒的

如果想要得到正确的报错提醒,第一种方法是设置明确的返回值,第二种方法是利用 never 类型.

第一种方法是设置明确的返回值
// 会报错:函数缺少结束返回语句,返回类型不包括 "undefined"
function area(s: Shape): number {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
      break;
    case "rectangle":
      return s.width * s.height;
      break;
  }
}
第二种方法是利用never类型,原理是在最后default判断分支写一个函数,设置参数是never类型,然后把最外面函数的参数传进去,正常情况下是不会执行到default分支的。
function area(s: Shape) {
  switch (s.kind) {
    case "square":
      return s.size * s.size;
      break;
    case "rectangle":
      return s.width * s.height;
      break;
    case "circle":
      return Math.PI * s.r ** 2;
      break;
    default:
      return ((e: never) => { throw new Error(e) })(s)
      //这个函数就是用来检查s是否是never类型,如果s是never类型,说明前面的分支全部覆盖了,如果s不是never类型,说明前面的分支有遗漏,就得需要补一下。
  }
}

索引类型

索引类型的查询操作符

keyof T 表示类型T的所有公共属性的字面量的联合类型

interface Obj {
  a: number;
  b: string;
}
let key: keyof Obj
// key的类型就是Obj的属性a和b的联合类型:let key: "a" | "b"

索引访问操作符

T[K] 表示对象T的属性K所代表的类型

interface Obj {
  a: number;
  b: string;
}
let value: Obj['a']
// value的类型就是Obj的属性a的类型: let value: number

泛型约束

T extends U 泛型变量可以继承某个类型获得某些属性

先看如下代码片段存在的问题。

let obj = {
  a: 1,
  b: 2,
  c: 3
}

//如下函数如果访问obj中不存在的属性也是没有报错的。
function getValues(obj: any, keys: string[]) {
  return keys.map(key => obj[key])
}

console.log(getValues(obj, ['a', 'b']))
console.log(getValues(obj, ['e', 'f']))
// 会显示[undefined, undefined],但是TS编译器并没有报错。

解决如下

function getValuest<T, K extends keyof T>(obj: T, keys: K[]): T[K][] {
  return keys.map(key => obj[key])
}
console.log(getValuest(obj, ['a', 'b']))
// console.log(getValuest(obj, ['e', 'f'])) // 这样就会报错了

映射类型

可以从一个旧的类型,生成一个新的类型

以下代码用到了TS内置的映射类型

interface Obj {
  a: string;
  b: number;
  c: boolean
}

// 以下三种类型称为同态,只会作用于Obj的属性,不会引入新的属性
//把一个接口的所有属性变成只读
type ReadonlyObj = Readonly<Obj>
//把一个接口的所有属性变成可选
type PartialObj = Partial<Obj>
//可以抽取接口的子集
type PickObj = Pick<Obj, 'a' | 'b'>

// 非同态 会创建新的属性
type RecordObj = Record<'x' | 'y', Obj>
// 创建一个新的类型并引入指定的新的类型为
// {
//     x: Obj;
//     y: Obj;
// }

条件类型

T extends U ? X : Y

type TypeName<T> =
  T extends string ? "string" :
  T extends number ? "number" :
  T extends boolean ? "boolean" :
  T extends undefined ? "undefined" :
  T extends Function ? "function" :
  "object"

type T1 = TypeName<string> // 得到的类型即: type T1 = "string"
type T2 = TypeName<string[]> // 得到的类型即:type T2 = "object"

分布式条件类型

(A | B) extends U ? X : Y 等价于 (A extends U ? X : Y) | (B extends U ? X : Y)

// 接上文
type T3 = TypeName<string | string[]> // 得到的类型即:type T3 = "string" | "object"

用法一:利用分布式条件类型可以实现 Diff 操作

type T4 = Diff<"a" | "b" | "c", "a" | "e"> // 即:type T4 = "b" | "c"
// 拆分一下具体步骤
// Diff<"a","a" | "e"> | Diff<"b","a" | "e"> | Diff<"c", "a" | "e">
// 分布结果如下:never | "b" | "c"
// 最终获得字面量的联合类型 "b" | "c"

用法二:在Diff的基础上实现过滤掉 null 和 undefined 的值。

type NotNull<T> = Diff<T, undefined | null>
type T5 = NotNull<string | number | undefined | null> // 即:type T5 = string | number

以上的类型别名在TS的类库中都有内置的类型

  • Diff => Exclude<T, U>
  • NotNull => NonNullable<T>

此外,内置的还有很多类型,比如从类型T中抽取出可以赋值给U的类型 Extract<T, U>

type T6 = Extract<"a" | "b" | "c", "a" | "e"> // 即:type T6 = "a"

比如: 用于提取函数类型的返回值类型 ReturnType<T>

先写出 ReturnType<T> 的实现,infer 表示在 extends 条件语句中待推断的类型变量。

type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

分析一下上面的代码,首先要求传入 ReturnType 的 T 必须能赋值给一个最宽泛的函数,之后判断 T 能不能赋值给一个可以接受任意参数的返回值待推断为 R 的函数,如果可以,返回待推断返回值 R ,如果不可以,返回 any 。

type T7 = ReturnType<() => string> //即:type T7 = string
查看原文

naice 发布了文章 · 2019-10-10

vue3的数据响应原理和实现

话说vue3已经发布,就引起了大量前端人员的关注,木得办法,学不动也得硬着头皮学呀,本篇文章就简单介绍一下「vue3的数据响应原理」,以及简单实现其reactiveeffectcomputed函数,希望能对大家理解vue3响应式有一点点的帮助。话不多说,看下面栗子的代码和其运行的结果。

<div id="root"></div>
<button id="btn">年龄+1</button>
const root = document.querySelector('#root')
const btn = document.querySelector('#btn')
const ob = reactive({
  name: '张三',
  age: 10
})

let cAge = computed(() => ob.age * 2)
effect(() => {
  root.innerHTML = `<h1>${ob.name}---${ob.age}---${cAge.value}</h1>`
})
btn.onclick = function () {
  ob.age += 1
}

上面带代码,是每点击一次按钮,就会给obj.age + 1 然后执行effect,计算属性也会相应的 ob.age * 2 执行,如下图:

vue3-project

所以,针对上面的栗子,制定一些小目标,然后一一实现,如下:

  • 1、实现reactive函数
  • 2、实现effect函数
  • 3、把reactive 和 effect 串联起来
  • 4、实现computed函数

实现reactive函数

reactive其实数据响应式函数,其内部通过es6proxy api 来实现,
下面面其实通过简单几行代码,就可以对一个对象进行代理拦截了。

const handlers = {
  get (target, key, receiver) {
    return Reflect.get(target, key, receiver)
  },
  set (target, key, value, receiver) {
    return Reflect.set(target, key, value, receiver)
  }
}
function reactive (target) {
  observed = new Proxy(target, handlers)
  return observed
}
let person = {
  name: '张三',
  age: 10
}

let ob = reactive(person)

但是这么做的话有缺点,1、重复多次写ob = reactive(person)就会一直执行new Proxy,这不是我们想要的。理想情况应该是,代理过的对象缓存下来,下次访问直接返回缓存对象就可以了;2、同理多次这么写ob = reactive(person); ob = reactive(ob) 那也要缓存下来。下面我们改造一下上面的reactive函数代码。

const toProxy = new WeakMap() // 缓存代理过的对象
const toRaw = new WeakMap() // 缓存被代理过的对象
// handlers 跟上面的一样,为了篇幅这里省略
function reactive (target) {
  let observed = toProxy.get(target)
  // 如果是缓存代理过的
  if (observed) {
    return observed
  }
  if (toRaw.has(target)) {
    return target
  }
  observed = new Proxy(target, handlers)
  toProxy.set(target, observed) // 缓存observed
  toRaw.set(observed, target) // 缓存target
  return observed
}

let person = {
  name: '张三',
  age: 10
}

let ob = reactive(person)
ob = reactive(person) // 返回都是缓存的
ob = reactive(ob) // 返回都是缓存的

console.log(ob.age) // 10
ob.age = 20
console.log(ob.age) // 20

这样子调用reactive()返回都是我们第一次的代理对象啦(ps:WeakMap是弱引用)。缓存做好了,但是还有新的问题,如果代理target对象层级嵌套比较深的话,上面的proxy是做不到深层代理的。例如

let person = {
  name: '张三',
  age: 10,
  hobby: {
    paly: ['basketball', 'football']
  }
}
let ob = reactive(person)
console.log(ob)

vue3-proxy

从上面的打印结果可以看出hobby 对象没有我们上面的handlers 代理,也就是说当我们对hobby做一些依赖收集的时候是没有办法的,所以我们改写一下handlers对象。

// 对象类型判断
const isObject = val => val !== null && typeof val === 'object'
const toProxy = new WeakMap() // 缓存代理过的对象
const toRaw = new WeakMap() // 缓存被代理过的对象
const handlers = {
  get (target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    // TODO: effect 收集
    return isObject(res) ? reactive(res) : res
  },
  set (target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    // TODO: trigger effect
    return result
  }
}
function reactive (target) {
  let observed = toProxy.get(target)
  // 如果是缓存代理过的
  if (observed) {
    return observed
  }
  if (toRaw.has(target)) {
    return target
  }
  observed = new Proxy(target, handlers)
  toProxy.set(target, observed) // 缓存observed
  toRaw.set(observed, target) // 缓存target
  return observed
}

上面的代码通过在get里面添加 return isObject(res) ? reactive(res) : res,意思是当访问到某一个对象时候,如果判断类型是「object」,那么就继续调用reactive代理。上面也是我们的reactive函数的完整代码。

实现effect函数

到了这里离我们的目标又近了一步,这里来实现effect函数,首先我们先看看effect的用法。

effect(() => {
  root.innerHTML = `<h1>${ob.name}---${ob.age}---${cAge.value}</h1>`
})

第一感觉看起来很简单嘛,就是函数当做参数传进去,然后调用传进来函数,完事。下面代码最简单实现

function effect(fn) {
  fn()
}

但是到这里,所有人都看出来缺点了,这只是执行一次呀?怎么跟响应式联系起来呀?还有后面computed怎么基于这个实现呀?等等。带着一大堆问题,通过改写effect和增加effect功能去解决这一系列问题。

function effect (fn, options = {}) {
  const effect = createReactiveEffect(fn, options)
  // 不是理解计算的,不需要调用此时调用effect
  if (!options.lazy) {
    effect()
  }
  return effect
}
function createReactiveEffect(fn, options) {
  const effect = function effect(...args) {
    return run(effect, fn, args) // 里面执行fn
  }
  // 给effect挂在一些属性
  effect.lazy = options.lazy
  effect.computed = options.computed
  effect.deps = []
  return effect
}

createReactiveEffect函数中:创建一个新的effect函数,并且给这个effect函数挂在一些属性,为后面做computed准备,这个effect函数里面调用run函数(此时还没有实现), 最后在返回出新的effect

effect函数中:如果判断options.lazyfalse就调用上面创建一个新的effect函数,里面会调用run函数。

把reactive 和 effect 串联起来

其实上面还没有写好的这个run函数的作用,就是把reactive effect 的逻辑串联起来,下面去实现它,目标又近了一步。

const activeEffectStack = [] // 声明一个数组,来存储当前的effect,订阅时候需要
function run (effect, fn, args) {
  if (activeEffectStack.indexOf(effect) === -1) {
    try {
      // 把effect push到数组中
      activeEffectStack.push(effect)
      return fn(...args)
    }
    finally {
      // 清除已经收集过得effect,为下个effect做准备
      activeEffectStack.pop()
    }
  }
}

上面的代码,把传进来的effect推送到一个activeEffectStack数组中,然后执行传进来的fn(...args),这里的fn就是

fn = () => {
  root.innerHTML = `<h1>${ob.name}---${ob.age}---${cAge.value}</h1>`
}

执行上面的fn访问到ob.nameob.agecAge.value(这是computed得来的),这样子就会触发到proxygetter,就是执行到下面的handlers.get函数

const handlers = {
  get (target, key, receiver) {
    const res = Reflect.get(target, key, receiver)
    // effect 收集
    track(target, key)
    return isObject(res) ? reactive(res) : res
  },
  set (target, key, value, receiver) {
    const result = Reflect.set(target, key, value, receiver)
    const extraInfo = { oldValue: target[key], newValue: value }
    // trigger effect
    trigger(target, key, extraInfo)
    return result
  }
}

聪明的小伙伴看到这里已经看出来,上面handlers.get函数里面track的作用是依赖收集,而handlers.set里面trigger是做派发更新的。
下面补全track函数代码

// 存储effect
const targetMap = new WeakMap()
function track (target, key) {
  // 拿到上面push进来的effect
  const effect = activeEffectStack[activeEffectStack.length - 1]
  if (effect) {
    let depsMap = targetMap.get(target)
    if (depsMap === void 0) {
      depsMap = new Map()
      // targetMap如果不存在target 的 Map 就设置一个
      targetMap.set(target, depsMap)
    }
    let dep = depsMap.get(key)
    if (dep === void 0) {
      dep = new Set()
      // 如果depsMap里面不存在key 的 Set 就设置一个
      depsMap.set(key, dep)
    }
    if (!dep.has(effect)) {
      // 收集当前的effect
      dep.add(effect)
      // effect 收集当前的dep
      effect.deps.push(dep)
    }
  }
}

看到这里呀,大家别方,上面的代码意思就是,从run函数里面的activeEffectStack拿到当前的effect,如果有effect,就从targetMap里面拿depsMaptargetMap如果不存在targetMap 就设置一个targetMap.set(target, depsMap),再从depsMap 里面拿 keySet ,如果depsMap里面不存在 keySet 就设置一个depsMap.set(key, dep),下面就是收集前的effecteffect 收集当前的dep了。收集完毕后,targetMap的数据结构就类似下面的样子的了。

// track的作用就是完成下面的数据结构
targetMap = {
  target: {
    name: [effect],
    age: [effect]
  }
}
// ps: targetMap 是WeakMap 数据结构,为了直观和理解就用对象表示
//     [effect] 是 Set数据结构,为了直观和理解就用数组表示

track执行完毕之后,handlers.get就会返回 res,进行一系列收集之后,fn执行完毕,run函数最后就执行finally {activeEffectStack.pop()},因为effect已经收集结束了,清空为了下一个effect收集做处理。

依赖收集已经完毕了,但是当我们更新数据的时候,例如ob.age += 1,更改数据会触发proxygetter,也就是会调用handlers.set函数,里面就执行了trigger(target, key, extraInfo)trigger函数如下

// effect 的触发
function trigger(target, key, extraInfo) {
  // 拿到所有target的订阅
  const depsMap = targetMap.get(target)
  // 没有被订阅到
  if (depsMap === void 0) {
    return;
  }
  const effects = new Set() // 普通的effect
  const computedRunners = new Set() // computed 的 effect
  if (key !== void 0) {
    let deps = depsMap.get(key)
    // 拿到deps订阅的每个effect,然后放到对应的Set里面
    deps.forEach(effect => {
      if (effect.computed) {
        computedRunners.add(effect)
      } else {
        effects.add(effect)
      }
    })
  }
  const run = effect => {
    effect()
  }
  // 循环调用effect
  computedRunners.forEach(run)
  effects.forEach(run)
}

上面的代码的意思是,拿到对应keyeffect,然后执行effect,然后执行run,然后执行fn,然后就是get上面那一套流程了,最后拿到数据是更改后新的数据,然后更改视图。

下面简单弄一个帮助理解的流程图,实在不能理解,大家把仓库代码拉下来,debuger执行一遍

targetMap = {
  name: [effect],
  age: [effect]
}
ob.age += 1 -> set() -> trigger() -> age: [effect] -> effect() -> run() -> fn() -> getget() -> 渲染视图

实现computed函数

还是先看用法,let cAge = computed(() => ob.age * 2),上面写effect的时候,有很多次提到为computed做准备,其实computed就是基于effect来实现的,下面我们看代码

function computed(fn) {
  const getter = fn
  // 手动生成一个effect,设置参数
  const runner = effect(getter, { computed: true, lazy: true })
  // 返回一个对象
  return {
    effect: runner,
    get value() {
      value = runner()
      return value
    }
  }
}

值得注意的是,我们上面 effet函数里面有个判断

if (!options.lazy) {
  effect()
}

如果options.lazytrue就不会立刻执行,就相当于let cAge = computed(() => ob.age * 2)不会立刻执行runner函数,当cAge.value才真正的执行。

最后,所有的函数画成一张流程图。

如果文章有哪些不对,请各位大佬指出来,我有摸鱼时间一定会修正过来的。

vue3-reactive

至此,所有的的小目标我们都已经完成了,撒花✿✿ヽ(°▽°)ノ✿

ps:

源码地址(大家可以clone下来执行一遍)

博客文章地址(这里有新的阅读体验,也有微信,欢迎来撩)

查看原文

赞 12 收藏 8 评论 3

naice 赞了文章 · 2019-09-04

requestAnimationFrame 方法你真的用对了吗?

requestAnimationFrame 方法让我们可以在下一帧开始时调用指定函数。但是很多人可能不知道,不管三七二十一直接在 requestAnimationFrame 的回调函数里绘制动画会有一个问题。是什么问题呢?要理解这个问题,我们先要了解 requestAnimationFrame 的一个知识点。

requestAnimationFrame 不管理回调函数

这个知识点就是 requestAnimationFrame 不管理回调函数。这一点在 w3c 中明确说明了。

Also note that multiple calls to requestAnimationFrame with the same callback (before callbacks are invoked and the list is cleared) will result in multiple entries being in the list with that same callback, and thus will result in that callback being invoked more than once for the animation frame.
w3c

即在回调被执行前,多次调用带有同一回调函数的 requestAnimationFrame,会导致回调在同一帧中执行多次。我们可以通过一个简单的例子模拟在同一帧内多次调用 requestAnimationFrame 的场景:

const animation = timestamp => console.log('animation called at', timestamp)

window.requestAnimationFrame(animation)
window.requestAnimationFrame(animation)
// animation called at 320.7559999991645
// animation called at 320.7559999991645

我们用连续调用两次 requestAnimationFrame 模拟在同一帧中调用两次 requestAnimationFrame

例子中的 timestamp 是由 requestAnimationFrame 传给回调函数的,表示回调队列被触发的时间。由输出可知,animation 函数在同一帧内被执行了两次,即绘制了两次动画。然而在同一帧绘制两次动画很明显是多余的,相当于画了一幅画,然后再在这幅画上再画上同样的一幅画。

问题

那么什么场景下,requestAnimationFrame 会在一帧内被多次调用呢?熟悉事件的同学应该马上能想到 mousemove, scroll 这类事件。

所以前面我们提到的问题就是:因为 requestAnimationFrame 不管理回调函数,在滚动、触摸这类高触发频率的事件回调里,如果调用 requestAnimationFrame 然后绘制动画,可能会造成多余的计算和绘制。例如:

window.addEventListener('scroll', e => {
    window.requestAnimationFrame(timestamp => {
        animation(timestamp)
    })
})

在上面代码中,scroll 事件可能在一帧内多次触发,所以 animation 函数可能会在一帧内重复绘制,造成不必要的计算和渲染。

解决方法

对于这种高频发事件,一般的解决方法是使用节流函数。但是在这里使用节流函数并不能完美解决问题。因为节流函数是通过时间管理队列的,而 requestAnimationFrame 的触发时间是不固定的,在高刷新频率的显示屏上时间会小于 16.67ms,页面如果被推入后台,时间可能大于 16.67ms。

完美的解决方案是通过 requestAnimationFrame 来管理队列,其思路就是保证 requestAnimationFrame 的队列里,同样的回调函数只有一个。示意代码如下:

const onScroll = e => {
    if (scheduledAnimationFrame) { return }

    scheduledAnimationFrame = true
    window.requestAnimationFrame(timestamp => {
        scheduledAnimationFrame = false
        animation(timestamp)
    })
}
window.addEventListener('scroll', onScroll)

但是每次都要写这么一堆代码,也有点麻烦。所以我开源了 raf-plus 库用于解决这个问题,有需要的的同学可以用用~

结论

requestAnimationFrame 不管理回调函数队列,而滚动、触摸这类高触发频率事件的回调可能会在同一帧内触发多次。所以正确使用 requestAnimationFrame 的姿势是,在同一帧内可能调用多次 requestAnimationFrame 时,要管理回调函数,防止重复绘制动画。

查看原文

赞 38 收藏 46 评论 7

naice 收藏了文章 · 2019-09-02

使用Blob进行文件上传

Blob,Binary Large Object的缩写,二进制类型的大对象,代表不可改变的原始数据

Blob基本用法

Blob对象

Blob对象指的是字节序列,并且具有size属性,是字节序列中的字节总数,和一个type属性,它是小写的ASCII编码的字符串表示的媒体类型字节序列。

size:以字节数返回字节序列的大小。获取时,符合要求的用户代理必须返回一个FileReader或一个FileReaderSync对象可以读取的总字节数,如果Blob没有要读取的字节,则返回0 。
type:小写的ASCII编码字符串表示媒体类型Blob。在获取时,用户代理必须Blob以小写形式返回a类型的ASCII编码字符串,这样当它转换为字节序列时,它是可解析的MIME类型,或者是空字符串(0字节)如果是类型无法确定。

构造函数

创建blob对象本质上和创建一个其他对象的方式是一样的,都是使用Blob() 的构造函数来进行创建。 构造函数接受两个参数:

第一个参数为一个数据序列,格式可以是ArrayBuffer, ArrayBufferView, Blob, DOMString
第二个参数是一个包含以下两个属性的对象
  • type: MIME的类型,
  • endings: 决定第一个参数的数据格式。默认值为"transparent",用于指定包含行结束符n的字符串如何被写入。 它是以下两个值中的一个: "native",表示行结束符会被更改为适合宿主操作系统文件系统的换行符; "transparent",表示会保持blob中保存的结束符不变。
    var data1 = "a";
    var blob1 = new Blob([data1]);
    console.log(blob1);  //输出:Blob {size: 1, type: ""}
    
    var debug = {hello: "world"};
    var blob = new Blob([JSON.stringify(debug, null, 2)],{type : 'application/json'});
    console.log(blob)   //  输出  Blob(22) {size: 22, type: "application/json"}
    
    // 创建一个8字节的ArrayBuffer,在其上创建一个每个数组元素为2字节的“视图”
    var abf = new ArrayBuffer(8)
    var abv = new Int16Array(abf)
    var bolb_ArrayBuffer = new Blob(abv, {type : 'text/plain'})
    console.log(bolb_ArrayBuffer)      //输出 Blob(4) {size: 4, type: "text/plain"}

slice方法

Blob对象有一个slice方法,返回一个新的 Blob对象,包含了源 Blob对象中指定范围内的数据。

slice(start, end, contentType)
start: 可选,代表 Blob 里的下标,表示第一个会被会被拷贝进新的 Blob 的字节的起始位置。如果传入的是一个负数,那么这个偏移量将会从数据的末尾从后到前开始计算。
end: 可选,代表的是 Blob 的一个下标,这个下标-1的对应的字节将会是被拷贝进新的Blob 的最后一个字节。如果你传入了一个负数,那么这个偏移量将会从数据的末尾从后到前开始计算。
contentType: 可选,给新的 Blob 赋予一个新的文档类型。这将会把它的 type 属性设为被传入的值。它的默认值是一个空的字符串。
var data = "abcdef";
var blob1 = new Blob([data]);
var blob2 = blob1.slice(0,3);

console.log(blob1);  //输出:Blob {size: 6, type: ""}
console.log(blob2);  //输出:Blob {size: 3, type: ""}

slice用于文件分片上传

  • 分片与并发结合,将一个大文件分割成多块,并发上传,极大地提高大文件的上传速度。
  • 当网络问题导致传输错误时,只需要重传出错分片,而不是整个文件。另外分片传输能够更加实时的跟踪上传进度。

分片上传逻辑如下:

  • 获取要上传文件的File对象,根据chunk(每片大小)对文件进行分片
  • 通过post方法轮循上传每片文件,其中url中拼接querystring用于描述当前上传的文件信息;post body中存放本次要上传的二进制数据片段
  • 接口每次返回offset,用于执行下次上传
initUpload();

//初始化上传
function initUpload() {
    var chunk = 100 * 1024;   //每片大小
    var input = document.getElementById("file");    //input file
    input.onchange = function (e) {
        var file = this.files[0];
        var query = {};
        var chunks = [];
        if (!!file) {
            var start = 0;
            //文件分片
            for (var i = 0; i < Math.ceil(file.size / chunk); i++) {
                var end = start + chunk;
                chunks[i] = file.slice(start , end);
                start = end;
            }
            
            // 采用post方法上传文件
            // url query上拼接以下参数,用于记录上传偏移
            // post body中存放本次要上传的二进制数据
            query = {
                fileSize: file.size,
                dataSize: chunk,
                nextOffset: 0
            }

            upload(chunks, query, successPerUpload);
        }
    }
}

// 执行上传
function upload(chunks, query, cb) {
    var queryStr = Object.getOwnPropertyNames(query).map(key => {
        return key + "=" + query[key];
    }).join("&");
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "http://xxxx/opload?" + queryStr);
    xhr.overrideMimeType("application/octet-stream");
    
    //获取post body中二进制数据
    var index = Math.floor(query.nextOffset / query.dataSize);
    getFileBinary(chunks[index], function (binary) {
        if (xhr.sendAsBinary) {
            xhr.sendAsBinary(binary);
        } else {
            xhr.send(binary);
        }

    });

    xhr.onreadystatechange = function (e) {
        if (xhr.readyState === 4) {
            if (xhr.status === 200) {
                var resp = JSON.parse(xhr.responseText);
                // 接口返回nextoffset
                // resp = {
                //     isFinish:false,
                //     offset:100*1024
                // }
                if (typeof cb === "function") {
                    cb.call(this, resp, chunks, query)
                }
            }
        }
    }
}

// 每片上传成功后执行
function successPerUpload(resp, chunks, query) {
    if (resp.isFinish === true) {
        alert("上传成功");
    } else {
        //未上传完毕
        query.offset = resp.offset;
        upload(chunks, query, successPerUpload);
    }
}

// 获取文件二进制数据
function getFileBinary(file, cb) {
    var reader = new FileReader();
    reader.readAsArrayBuffer(file);
    reader.onload = function (e) {
        if (typeof cb === "function") {
            cb.call(this, this.result);
        }
    }
}

Blob URL

blob协议的url使用时就像平时使用的url一样,可以作为图片请求地址,也可以作为文件请求地址。格式:

blob:http://XXX
  • URL.createObjectURL(blob) 创建链接
  • URL.revokeObjectURL(url)

下面是一个下载文件的示例,直接调用即可实现文件下载

// file是要下载的文件(blob对象)
downloadHandler: function (file, fileName) {
  let link = document.createElement('a')
  link.href = window.URL.createObjectURL(file)
  link.download = fileName
  link.click()
  window.URL.revokeObjectURL(link.href)
  if (navigator.userAgent.indexOf('Firefox') > -1) {
    const a = document.createElement('a')
    a.addEventListener('click', function (e) {
      a.download = fileName
      a.href = URL.createObjectURL(file)
    })
    let e = document.createEvent('MouseEvents')
    e.initEvent('click', false, false)
    a.dispatchEvent(e)
  }
}

在从后台获取的数据接口中把返回类型设置为blob

var x = new XMLHttpRequest();
x.responseType = 'blob';       // 返回一个blob对象   

Blob URL和Data URL的区别

Blob URL
blob URL
Data URL
data URL

  • Blob URL的长度一般比较短,但Data URL因为直接存储图片base64编码后的数据,往往很长,如上图所示,浏览器在显示Data URL时使用了省略号(…)。当显式大图片时,使用Blob URL能获取更好的可能性。
  • Blob URL可以方便的使用XMLHttpRequest获取源数据,比如设置XMLHttpRequest返回的数据类型为blob
  • Blob URL 只能在当前应用内部使用,把Blob URL复制到浏览器的地址栏中,是无法获取数据的。Data URL相比之下,就有很好的移植性,可以在任意浏览器中使用。
查看原文

naice 赞了文章 · 2019-09-02

使用Blob进行文件上传

Blob,Binary Large Object的缩写,二进制类型的大对象,代表不可改变的原始数据

Blob基本用法

Blob对象

Blob对象指的是字节序列,并且具有size属性,是字节序列中的字节总数,和一个type属性,它是小写的ASCII编码的字符串表示的媒体类型字节序列。

size:以字节数返回字节序列的大小。获取时,符合要求的用户代理必须返回一个FileReader或一个FileReaderSync对象可以读取的总字节数,如果Blob没有要读取的字节,则返回0 。
type:小写的ASCII编码字符串表示媒体类型Blob。在获取时,用户代理必须Blob以小写形式返回a类型的ASCII编码字符串,这样当它转换为字节序列时,它是可解析的MIME类型,或者是空字符串(0字节)如果是类型无法确定。

构造函数

创建blob对象本质上和创建一个其他对象的方式是一样的,都是使用Blob() 的构造函数来进行创建。 构造函数接受两个参数:

第一个参数为一个数据序列,格式可以是ArrayBuffer, ArrayBufferView, Blob, DOMString
第二个参数是一个包含以下两个属性的对象
  • type: MIME的类型,
  • endings: 决定第一个参数的数据格式。默认值为"transparent",用于指定包含行结束符n的字符串如何被写入。 它是以下两个值中的一个: "native",表示行结束符会被更改为适合宿主操作系统文件系统的换行符; "transparent",表示会保持blob中保存的结束符不变。
    var data1 = "a";
    var blob1 = new Blob([data1]);
    console.log(blob1);  //输出:Blob {size: 1, type: ""}
    
    var debug = {hello: "world"};
    var blob = new Blob([JSON.stringify(debug, null, 2)],{type : 'application/json'});
    console.log(blob)   //  输出  Blob(22) {size: 22, type: "application/json"}
    
    // 创建一个8字节的ArrayBuffer,在其上创建一个每个数组元素为2字节的“视图”
    var abf = new ArrayBuffer(8)
    var abv = new Int16Array(abf)
    var bolb_ArrayBuffer = new Blob(abv, {type : 'text/plain'})
    console.log(bolb_ArrayBuffer)      //输出 Blob(4) {size: 4, type: "text/plain"}

slice方法

Blob对象有一个slice方法,返回一个新的 Blob对象,包含了源 Blob对象中指定范围内的数据。

slice(start, end, contentType)
start: 可选,代表 Blob 里的下标,表示第一个会被会被拷贝进新的 Blob 的字节的起始位置。如果传入的是一个负数,那么这个偏移量将会从数据的末尾从后到前开始计算。
end: 可选,代表的是 Blob 的一个下标,这个下标-1的对应的字节将会是被拷贝进新的Blob 的最后一个字节。如果你传入了一个负数,那么这个偏移量将会从数据的末尾从后到前开始计算。
contentType: 可选,给新的 Blob 赋予一个新的文档类型。这将会把它的 type 属性设为被传入的值。它的默认值是一个空的字符串。
var data = "abcdef";
var blob1 = new Blob([data]);
var blob2 = blob1.slice(0,3);

console.log(blob1);  //输出:Blob {size: 6, type: ""}
console.log(blob2);  //输出:Blob {size: 3, type: ""}

slice用于文件分片上传

  • 分片与并发结合,将一个大文件分割成多块,并发上传,极大地提高大文件的上传速度。
  • 当网络问题导致传输错误时,只需要重传出错分片,而不是整个文件。另外分片传输能够更加实时的跟踪上传进度。

分片上传逻辑如下:

  • 获取要上传文件的File对象,根据chunk(每片大小)对文件进行分片
  • 通过post方法轮循上传每片文件,其中url中拼接querystring用于描述当前上传的文件信息;post body中存放本次要上传的二进制数据片段
  • 接口每次返回offset,用于执行下次上传
initUpload();

//初始化上传
function initUpload() {
    var chunk = 100 * 1024;   //每片大小
    var input = document.getElementById("file");    //input file
    input.onchange = function (e) {
        var file = this.files[0];
        var query = {};
        var chunks = [];
        if (!!file) {
            var start = 0;
            //文件分片
            for (var i = 0; i < Math.ceil(file.size / chunk); i++) {
                var end = start + chunk;
                chunks[i] = file.slice(start , end);
                start = end;
            }
            
            // 采用post方法上传文件
            // url query上拼接以下参数,用于记录上传偏移
            // post body中存放本次要上传的二进制数据
            query = {
                fileSize: file.size,
                dataSize: chunk,
                nextOffset: 0
            }

            upload(chunks, query, successPerUpload);
        }
    }
}

// 执行上传
function upload(chunks, query, cb) {
    var queryStr = Object.getOwnPropertyNames(query).map(key => {
        return key + "=" + query[key];
    }).join("&");
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "http://xxxx/opload?" + queryStr);
    xhr.overrideMimeType("application/octet-stream");
    
    //获取post body中二进制数据
    var index = Math.floor(query.nextOffset / query.dataSize);
    getFileBinary(chunks[index], function (binary) {
        if (xhr.sendAsBinary) {
            xhr.sendAsBinary(binary);
        } else {
            xhr.send(binary);
        }

    });

    xhr.onreadystatechange = function (e) {
        if (xhr.readyState === 4) {
            if (xhr.status === 200) {
                var resp = JSON.parse(xhr.responseText);
                // 接口返回nextoffset
                // resp = {
                //     isFinish:false,
                //     offset:100*1024
                // }
                if (typeof cb === "function") {
                    cb.call(this, resp, chunks, query)
                }
            }
        }
    }
}

// 每片上传成功后执行
function successPerUpload(resp, chunks, query) {
    if (resp.isFinish === true) {
        alert("上传成功");
    } else {
        //未上传完毕
        query.offset = resp.offset;
        upload(chunks, query, successPerUpload);
    }
}

// 获取文件二进制数据
function getFileBinary(file, cb) {
    var reader = new FileReader();
    reader.readAsArrayBuffer(file);
    reader.onload = function (e) {
        if (typeof cb === "function") {
            cb.call(this, this.result);
        }
    }
}

Blob URL

blob协议的url使用时就像平时使用的url一样,可以作为图片请求地址,也可以作为文件请求地址。格式:

blob:http://XXX
  • URL.createObjectURL(blob) 创建链接
  • URL.revokeObjectURL(url)

下面是一个下载文件的示例,直接调用即可实现文件下载

// file是要下载的文件(blob对象)
downloadHandler: function (file, fileName) {
  let link = document.createElement('a')
  link.href = window.URL.createObjectURL(file)
  link.download = fileName
  link.click()
  window.URL.revokeObjectURL(link.href)
  if (navigator.userAgent.indexOf('Firefox') > -1) {
    const a = document.createElement('a')
    a.addEventListener('click', function (e) {
      a.download = fileName
      a.href = URL.createObjectURL(file)
    })
    let e = document.createEvent('MouseEvents')
    e.initEvent('click', false, false)
    a.dispatchEvent(e)
  }
}

在从后台获取的数据接口中把返回类型设置为blob

var x = new XMLHttpRequest();
x.responseType = 'blob';       // 返回一个blob对象   

Blob URL和Data URL的区别

Blob URL
blob URL
Data URL
data URL

  • Blob URL的长度一般比较短,但Data URL因为直接存储图片base64编码后的数据,往往很长,如上图所示,浏览器在显示Data URL时使用了省略号(…)。当显式大图片时,使用Blob URL能获取更好的可能性。
  • Blob URL可以方便的使用XMLHttpRequest获取源数据,比如设置XMLHttpRequest返回的数据类型为blob
  • Blob URL 只能在当前应用内部使用,把Blob URL复制到浏览器的地址栏中,是无法获取数据的。Data URL相比之下,就有很好的移植性,可以在任意浏览器中使用。
查看原文

赞 30 收藏 22 评论 0

naice 收藏了文章 · 2019-03-11

electron 自动更新以及手动更新

从搭建开始 使用的是electron-vue 毕竟方便一点 如果只想安装electron 请参见我的另一个文章 https://segmentfault.com/a/11...
git.gif

首先安装Electron:

vue init simulatedgreg/electron-vue project1

cd project1
npm install //第一次安装的伙伴需要翻墙 如何翻墙请参加另一个文章(好像被和谐了 那就+我们的交流群吧!)

安装的时候安装了 vueelectronvue-router 不安装 vuex

打包选择的是: electron-builder 下次有时间再扯electron-packager

图片描述

安装完毕之后启动运行

npm run dev

构建页面

更新进度页面

将他写成组件 update.vue

图片描述

<template>
    <transition name="fade">
        <div v-if="show">
            <div class="modal"></div>
            <div class="update">
                <div class="header"><h2>应用更新</h2><i class="close" @click="close"></i></div>
                <div class="body">
                    <p>更新进度</p>
                    <p class="percentage">10%</p>
                    <div class="progress">
                        <div class="length"></div>
                    </div>
                </div>
            </div>
        </div>
    </transition>
</template>

<script>
    export default {
        name: "update",
        methods: {
            close() {
                this.$emit('update:show', false)
            }
        },
        props: {
            show: {
                type: Boolean,
                required: true,
                default: false
            }
        }
    }
</script>


<style>
    .fade-enter-active, .fade-leave-active {
        transition: opacity .5s;
    }
    .fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
        opacity: 0;
    }
    .modal {
        position: fixed;
        left: 0;
        top: 0;
        width: 100%;
        height: 100%;
        opacity: .4;
        background: #000;
    }

    .update {
        width: 400px;
        height: 180px;
        background-color: #FFFFFF;
        border-radius: 10px;
        border: 1px solid #CCC;
        position: absolute;
        top: 40%;
        margin-top: -90px;
        left: 50%;
        margin-left: -200px;
        box-shadow: #FFFFFF 0 0 10px;
    }

    .update .header i.close {
        display: inline-block;
        position: absolute;
        top: 11px;
        right: 12px;
        width: 20px;
        height: 20px;
        background-image: url("../assets/img/close.png");
        background-size: 100%;
        cursor: pointer;
    }

    .update .header {
        border-bottom: 1px solid #ccc;
        height: 40px;
        line-height: 40px;
    }

    .update .header h2 {
        text-align: center;
        font-size: 20px;
    }

    .update .body {
        padding-top: 20px;
        text-align: center;
    }

    .update .body .percentage {
        margin-top: 20px;
    }

    .update .body .progress {
        width: 350px;
        height: 30px;
        border: 1px solid #CCCCCC;
        border-radius: 8px;
        margin: 10px auto;
    }

    .update .body .progress .length {
        background-color: #E4393c;
        border-radius: 8px;
        width: 10px;
        height: 30px;
    }
</style>

安装模块

安装 electron-updater 包模块

npm install electron-updater --save

图片描述

修改package.json
加入以下代码

    "publish": [
      {
        "provider": "generic",
        "url": "http://lee.com/app/update"
      }
    ],

图片描述

配置更新服务器

我们的更新服务器是本地虚拟主机 以apache为例

配置apache服务器

我本地使用的是集成环境 很简单的操作 要是大家使用自定义安装的 往httpd-vhosts.conf里面添加配置就可以了

我们的域名是lee.com

修改hosts文件

修改 hosts文件 往里面添加
文件地址在 C:WindowsSystem32driversetc目录下

127.0.0.1 lee.com

核心文件

主进程中 主要是handleUpdate方法

import {app, BrowserWindow, ipcMain} from 'electron'
// 注意这个autoUpdater不是electron中的autoUpdater
import {autoUpdater} from "electron-updater"

/**
 * Set `__static` path to static files in production
 * https://simulatedgreg.gitbooks.io/electron-vue/content/en/using-static-assets.html
 */
if (process.env.NODE_ENV !== 'development') {
    global.__static = require('path').join(__dirname, '/static').replace(/\\/g, '\\\\')
}

let mainWindow
const winURL = process.env.NODE_ENV === 'development'
    ? `http://localhost:9080`
    : `file://${__dirname}/index.html`

function createWindow() {
    /**
     * Initial window options
     */
    mainWindow = new BrowserWindow({
        height: 563,
        useContentSize: true,
        width: 1000
    })

    mainWindow.loadURL(winURL)

    mainWindow.on('closed', () => {
        mainWindow = null
    });


//处理更新操作
    function handleUpdate() {
        const returnData = {
            error: {status: -1, msg: '检测更新查询异常'},
            checking: {status: 0, msg: '正在检查应用程序更新'},
            updateAva: {status: 1, msg: '检测到新版本,正在下载,请稍后'},
            updateNotAva: {status: -1, msg: '您现在使用的版本为最新版本,无需更新!'},
        };

        //和之前package.json配置的一样
        autoUpdater.setFeedURL('http://xxx.com/app/update');

        //更新错误
        autoUpdater.on('error', function (error) {
            sendUpdateMessage(returnData.error)
        });

        //检查中
        autoUpdater.on('checking-for-update', function () {
            sendUpdateMessage(returnData.checking)
        });

        //发现新版本
        autoUpdater.on('update-available', function (info) {
            sendUpdateMessage(returnData.updateAva)
        });

        //当前版本为最新版本
        autoUpdater.on('update-not-available', function (info) {
            setTimeout(function () {
                sendUpdateMessage(returnData.updateNotAva)
            }, 1000);
        });

        // 更新下载进度事件
        autoUpdater.on('download-progress', function (progressObj) {
            mainWindow.webContents.send('downloadProgress', progressObj)
        });


        autoUpdater.on('update-downloaded', function (event, releaseNotes, releaseName, releaseDate, updateUrl, quitAndUpdate) {
            ipcMain.on('isUpdateNow', (e, arg) => {
                //some code here to handle event
                autoUpdater.quitAndInstall();
            });
            // win.webContents.send('isUpdateNow')
        });

        //执行自动更新检查
        autoUpdater.checkForUpdates();
    }

    handleUpdate();

// 通过main进程发送事件给renderer进程,提示更新信息
    function sendUpdateMessage(text) {
        mainWindow.webContents.send('message', text)
    }

    ipcMain.on("checkForUpdate", (event, data) => {
        console.log('执行自动更新检查!!!');
        // event.sender.send('reply', 'hi lee my name is yuan, age is 17');
        autoUpdater.checkForUpdates();
    });
}

app.on('ready', createWindow)

app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit()
    }
});

app.on('activate', () => {
    if (mainWindow === null) {
        createWindow()
    }
});

更新参数讲解

在有更新包的情况下会在主进程中触发下面的方法:

  autoUpdater.on('download-progress', function (progressObj) {
        // mainWindow.webContents.send('downloadProgress', progressObj)
        const winId = BrowserWindow.getFocusedWindow().id;
        let win = BrowserWindow.fromId(winId);
        win.webContents.send('downloadProgress', progressObj)
    });

progressObj :

 { "bytesPerSecond": 47132710, "delta": 39780007, "percent": 100, "total": 39780007, "transferred": 39780007 } 

bytesPerSecond: bps/s //传送速率
percent : 百分比 //我们需要这个就可以了
total : 总大小
transferred: 已经下载

发布更新

将新的安装包和latest.yml 放到对应的目录下 系统会自动去检测版本 如果有新版本会下载的!!

检测更新

创建触发更新的组件

    <div><h2>你好 我是1.2.4</h2>
        <button @click="updateApp" style="width:100px;height: 40px;">更新</button>
        <Update :show.sync="show" :percent="percent"></Update>
    </div>
</template>

<script>

    import Update from "@/components/update";

    export default {
        name: "index",
        components: {Update},
        data() {
            return {
               
                percent: 0,
                show: false
            }
        },
        mounted() {
            //更新进度
            this.$electron.ipcRenderer.on('downloadProgress', (event, data) => {
              
                this.percent = (data.percent).toFixed(2);
                if (data.percent >= 100) {
                    // this.show = false;
                }
            });

            /**
             * 主进程返回的检测状态
             */
            this.$electron.ipcRenderer.on('message', (event, data) => {
                switch (data.status) {
                    case -1:
                        this.$Message.error(data.msg);
                        break;
                    case 0:
                        this.$Message.loading(data.msg);
                        break;
                    case 1:
                        this.show = true;
                        break;
                }
            });
        },
        methods: {
            updateApp() {
                this.$electron.ipcRenderer.send('checkForUpdate', 'asdad')
            }
        }
    }
</script>

图片描述

总结

由于我的虚拟机是在本地 所以下载速度超快
后来我将更新地址切换到远程服务器 下面是操作截图

查看原文

naice 收藏了文章 · 2019-03-02

web前端性能优化总结

概括

涉及到的分类

  • 网络层面
  • 构建层面
  • 浏览器渲染层面
  • 服务端层面

涉及到的功能点

  • 资源的合并与压缩
  • 图片编解码原理和类型选择
  • 浏览器渲染机制
  • 懒加载预加载
  • 浏览器存储
  • 缓存机制
  • PWA
  • Vue-SSR

资源合并与压缩

http请求的过程及潜在的性能优化点

  • 理解减少http请求数量减少请求资源大小两个优化要点
  • 掌握压缩合并的原理
  • 掌握通过在线网站fis3两种实现压缩与合并的方法

浏览器的一个请求从发送到返回都经历了什么

动态的加载静态的资源

  • dns是否可以通过缓存减少dns查询时间
  • 网络请求的过程走最近的网络环境
  • 相同的静态资源是否可以缓存
  • 能否减少http请求大小
  • 能否减少http请求数量
  • 服务端渲染

资源的合并与压缩设计到的性能点

  • 减少http请求的数量
  • 减少请求的大小

html压缩

HTML代码压缩就是压缩这些在文本文件中有意义,但是在HTML中不显示的字符,包括空格,制表符,换行符等,还有一些其他意义的字符,如HTML注释也可以被压缩

意义

  • 大型网站意义比较大

如何进行html的压缩

  • 使用在线网站进行压缩(走构建工具多,公司级在线网站手动压缩小)
  • node.js提供了html-minifier工具
  • 后端模板引擎渲染压缩

cssjs压缩

css的压缩

  • 无效代码删除

    • 注释、无效字符
  • css语义合并

css压缩的方式

  • 使用在线网站进行压缩
  • 使用html-minifierhtml中的css进行压缩
  • 使用clean-csscss进行压缩

js的压缩语混乱

  • 无效字符的删除

    • 空格、注释、回车等
  • 剔除注释
  • 代码语意的缩减和优化

    • 变量名缩短(a,b)等
  • 代码保护

    • 前端代码是透明的,客户端代码用户是可以直接看到的,可以轻易被窥探到逻辑和漏洞

js压缩的方式

  • 使用在线网站进行压缩
  • 使用html-minifierhtml中的js进行压缩
  • 使用uglifyjs2js进行压缩

不合并文件可能存在的问题

  • 文件与文件有插入之间的上行请求,又增加了N-1个网络延迟
  • 受丢包问题影响更严重
  • 经过代理服务器时可能会被断开

文件合并缺点

  • 首屏渲染问题

    • 文件合并之后的js变大,如果首页的渲染依赖这个js的话,整个页面的渲染要等js请求完才能执行
    • 如果首屏只依赖a.js,只要等a.js完成后就可执行
    • 没有通过服务器端渲染,现在框架都需要等合并完的文件请求完才能执行,基本都需要等文件合并后的js
  • 缓存失效问题

    • 标记 js`md5`戳
    • 合并之后的js,任何一个改动都会导致大面积的缓存失效

文件合并对应缺点的处理

  • 公共库合并
  • 不同页面的合并

    • 不同页面js单独打包
  • 见机行事,随机应变

文件合并对应方法

  • 使用在线网站进行合并
  • 构建阶段,使用nodejs进行文件合并

图片相关优化

一张JPG的解析过程


jpg有损压缩:虽然损失一些信息,但是肉眼可见影响并不大

png8/png24/png32之间的区别

  • png8   ----256色 + 支持透明
  • png24 ----2^24 + 不支持透明
  • png32  ---2^24 +支持透明

文件大小 + 色彩丰富程度

png32是在png24上支持了透明,针对不同的业务场景选择不同的图片格式很重要

不同的格式图片常用的业务场景

不同格式图片的特点

  • jpg有损压缩,压缩率高,不支持透明
  • png支持透明,浏览器兼容性好
  • webp压缩程度更好,在ios webview中有兼容性问题
  • svg矢量图,代码内嵌,相对较小,图片样式相对简单的场景(尽量使用,绘制能力有限,图片简单用的比较多)

不同格式图片的使用场景

  • jpg:大部分不需要透明图片的业务场景
  • png:大部分需要透明图片的业务场景
  • webpandroid全部(解码速度和压缩率高于jpgpng,但是iossafari还没支持)
  • svg:图片样式相对简单的业务场景

图片压缩的几种情况

  • 针对真实图片情况,舍弃一些相对无关紧要的色彩信息
  • CSS雪碧图:把你的网站用到的一些图片整合到一张单独的图片中

    • 优点:减少HTTP请求的数量(通过backgroundPosition定位所需图片)
    • 缺点:整合图片比较大时,加载比较慢(如果这张图片没有加载成功,整个页面会失去图片信息)facebook官网任然在用,主要pc用的比较多,相对性能比较强
  • Image-inline:将图片的内容嵌到html中(减少网站的HTTP请求)

    • base64信息,减少网站的HTTP请求,如果图片比较小比较多,时间损耗主要在请求的骨干网络
  • 使用矢量图

    • 使用SVG进行矢量图的绘制
    • 使用icon-font解决icon问题
  • 在android下使用webp

    • webp的优势主要体现在它具有更优的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量;
    • 同时具备了无损和有损的压缩模式、Alpha透明以及动画的特性,在JPEGPNG上的转化效果都非常优秀、稳定和统一

cssjs的装载与执行

HTML页面加载渲染的过程

一个网站在浏览器端是如何进行渲染的

HTML渲染过程中的一些特点

  • 顺序执行,并发加载

    • 词法分析:从上到下依次解析

      • 通过HTML生成Token对象(当前节点的所有子节点生成后,才会通过next token获取到当前节点的兄弟节点),最终生成Dom Tree
    • 并发加载:资源请求是并发请求的
    • 并发上限

      • 浏览器中可以支持并发请求,不同浏览器所支持的并发数量不同(以域名划分),以Chrome为例,并发上限为6个
      • 优化点: 把CDN资源分布在多个域名下
  • 是否阻塞

    • css阻塞

      • csshead中通过link引入会阻塞页面的渲染

        • 如果我们把css代码放在head中去引入的话,那么我们整个页面的渲染实际上就会等待headcss加载并生成css树,最终和DOM整合生成RanderTree之后才会进行渲染
        • 为了浏览器的渲染,能让页面显示的时候视觉上更好。

避免某些情况,如:假设你放在页面最底部,用户打开页面时,有可能出现,页面先是显示一大堆文字或图片,自上而下,丝毫没有排版和样式可言。最后,页面又恢复所要的效果

    - `css`不阻塞`js`的加载,但阻塞`js`的执行
    - `css`不阻塞外部脚步的加载(`webkit preloader 预资源加载器`)
- `js`阻塞
    -  直接通过`<script src>`引入会阻塞后面节点的渲染
        -  `html parse`认为`js`会动态修改文档结构(`document.write`等方式),没有进行后面文档的变化
        -  `async`、`defer`(`async`放弃了依赖关系)
            - `defer`属性(`<script data-original="" defer></script>`) 

(这是延迟执行引入的js脚本(即脚本加载是不会导致解析停止,等到document全部解析完毕后,defer-script也加载完毕后,在执行所有的defer-script加载的js代码,再触发Domcontentloaded

            - `async`属性(`<script data-original="" async></script>`) 
                - 这是异步执行引入的`js`脚本文件 
                - 与`defer`的区别是`async`会在加载完成后就执行,但是不会影响阻塞到解析和渲染。但是还是会阻塞`load`事件,所以`async-script`会可能在`DOMcontentloaded`触发前或后执行,但是一定会在`load`事件前触发。



懒加载与预加载

懒加载

  • 图片进入可视区域之后请求图片资源
  • 对于电商等图片很多,页面很长的业务场景适用
  • 减少无效资源的加载
  • 并发加载的资源过多会会阻塞js的加载,影响网站的正常使用

img src被设置之后,webkit解析到之后才去请求这个资源。所以我们希望图片到达可视区域之后,img src才会被设置进来,没有到达可视区域前并不现实真正的src,而是类似一个1px的占位符。

场景:电商图片

预加载

  • 图片等静态资源在使用之前的提前请求
  • 资源使用到时能从缓存中加载,提升用户体验
  • 页面展示的依赖关系维护

场景:抽奖

懒加载原生jszepto.lazyload

原理

先将img标签中的src链接设为同一张图片(空白图片),将其真正的图片地址存储再img标签的自定义属性中(比如data-src)。当js监听到该图片元素进入可视窗口时,即将自定义属性中的地址存储到src属性中,达到懒加载的效果。

注意问题:
  • 关注首屏处理,因为还没滑动
  • 占位,图片大小首先需要预设高度,如果没有设置的话,会全部显示出来

var viewheight = document.documentElement.clientHeight   //可视区域高度

function lazyload(){
    var eles = document.querySelectorAll('img[data-original][lazyload]')

    Array.prototype.forEach.call(eles,function(item,index){
        var rect;
        if(item.dataset.original === '') return;
        rect = item.getBoundingClientRect(); //返回元素的大小及其相对于视口的

        if(rect.bottom >= 0 && rect.top < viewheight){
            !function(){
                var img = new Image();
                img.src = item.dataset.url;
                img.onload = function(){
                    item.src = img.src
                }
                item.removeAttribute('data-original');
                item.removeAttribute('lazyload');
            }()
        }
    })
}

lazyload()
document.addEventListener('scroll',lazyload)

预加载原生jspreloadJS实现

预加载实现的几种方式

  • 第一种方式:直接请求下来
<img data-original="https://user-gold-cdn.xitu.io/2019/2/21/1690d1b216cbfa18" style="display: none"/>
<img data-original="https://user-gold-cdn.xitu.io/2019/2/21/1690d1b21b70c8d2" style="display: none"/>
<img data-original="https://user-gold-cdn.xitu.io/2019/2/21/1690d1b216e17e26" style="display: none"/>
<img data-original="https://user-gold-cdn.xitu.io/2019/2/21/1690d1b217b3ae59" style="display: none"/>
  • 第二种方式:image对象
var image = new Image();
image.src = "www.pic26.com/dafdafd/safdas.jpg";
  • 第三种方式:xmlhttprequest

    • 缺点:存在跨域问题
    • 优点:好控制
var xmlhttprequest = new XMLHttpRequest();

xmlhttprequest.onreadystatechange = callback;

xmlhttprequest.onprogress = progressCallback;

xmlhttprequest.open("GET","http:www.xxx.com",true);

xmlhttprequest.send();

function callback(){
    if(xmlhttprequest.readyState == 4 && xmlhttprequest.status == 200){
        var responseText = xmlhttprequest.responseText;
    }else{
        console.log("Request was unsuccessful:" + xmlhttprequest.status);
    }
}

function progressCallback(){
    e = e || event;
    if(e.lengthComputable){
        console.log("Received"+e.loaded+"of"+e.total+"bytes")
    }
}   

 

PreloadJS模块

  • 本质权衡浏览器加载能力,让它尽可能饱和利用起来

重绘与回流

css性能让javascript变慢

要把css相关的外部文件引入放进head中,加载css时,整个页面的渲染是阻塞的,同样的执行javascript代码的时候也是阻塞的,例如javascript死循环。

一个线程   =>  javascript解析
一个线程   =>  UI渲染

这两个线程是互斥的,当UI渲染的时候,javascript的代码被终止。当javascript代码执行,UI线程被冻结。所以css的性能让javascript变慢。

频繁触发重绘与回流,会导致UI频繁渲染,最终导致js变慢

什么是重绘和回流

回流

  • render tree中的一部分(或全部)因为元素的规模尺寸布局隐藏等改变而需要重新构建。这就成为回流(reflow)
  • 页面布局和几何属性改变时,就需要回流

重绘

  • render tree中的一些元素需要更新属性,而这些属性只是影响元素的外观风格,而不影响布局,比如background-color。就称重绘

关系

用到chrome 分析 performance

回流必将引起重绘,但是重绘不一定会引起回流

避免重绘、回流的两种方法

触发页面重布局的一些css属性

  • 盒子模型相关属性会触发重布局

    • width
    • height
    • padding
    • margin
    • display
    • border-width
    • border
    • min-height
  • 定位属性及浮动也会触发重布局

    • top
    • bottom
    • left
    • right
    • position
    • float
    • clear
  • 改变节点内部文字结构也会触发重布局
  • text-align
  • overflow-y
  • font-weight
  • overflow
  • font-family
  • line-height
  • vertical-align
  • white-space
  • font-size

优化点:使用不触发回流的方案替代触发回流的方案

只触发重绘不触发回流

  • color
  • border-styleborder-radius
  • visibility
  • text-decoration
  • backgroundbackground-imagebackground-positionbackground-repeatbackground-size
  • outlineoutline-coloroutline-styleoutline-width
  • box-shadow

新建DOM的过程

  • 获取DOM后分割为多个图层
  • 对每个图层的节点计算样式结果(Recalculate style 样式重计算)
  • 为每个节点生成图形和位置(Layout 回流和重布局)
  • 将每个节点绘制填充到图层位图中(Paint SetupPaint重绘)
  • 图层作为纹理上传至gpu
  • 符合多个图层到页面上生成最终屏幕图像(Composite Layers 图层重组)

浏览器绘制DOM的过程是这样子的:

  • 获取 DOM 并将其分割为多个层(layer),将每个层独立地绘制进位图(bitmap)中
  • 将层作为纹理(texture)上传至 GPU,复合(composite)多个层来生成最终的屏幕图像
  • left/top/margin之类的属性会影响到元素在文档中的布局,当对布局(layout)进行动画时,该元素的布局改变可能会影响到其他元素在文档中的位置,就导致了所有被影响到的元素都要进行重新布局,浏览器需要为整个层进行重绘并重新上传到 GPU,造成了极大的性能开销。
  • transform 属于合成属性(composite property),对合成属性进行 transition/animation 动画将会创建一个合成层(composite layer),这使得被动画元素在一个独立的层中进行动画。
  • 通常情况下,浏览器会将一个层的内容先绘制进一个位图中,然后再作为纹理(texture)上传到 GPU,只要该层的内容不发生改变,就没必要进行重绘(repaint),浏览器会通过重新复合(recomposite)来形成一个新的帧。

chrome创建图层的条件

将频繁重绘回流的DOM元素单独作为一个独立图层,那么这个DOM元素的重绘和回流的影响只会在这个图层中

  • 3D或透视变换
  • CSS 属性使用加速视频解码的 <video> 元素
  • 拥有 3D (WebGL) 上下文或加速的
  • 2D 上下文的 <canvas> 元素
  • 复合插件(如 Flash)
  • 进行 opacity/transform 动画的元素拥有加速
  • CSS filters 的元素元素有一个包含复合层的后代节点(换句话说,就是一个元素拥有一个子元素,该子元素在自己的层里)
  • 元素有一个 z-index 较低且包含一个复合层的兄弟元素(换句话说就是该元素在复合层上面渲染)
总结:对布局属性进行动画,浏览器需要为每一帧进行重绘并上传到 GPU 中对合成属性进行动画,浏览器会为元素创建一个独立的复合层,当元素内容没有发生改变,该层就不会被重绘,浏览器会通过重新复合来创建动画帧

gif图

总结

  • 尽量避免使用触发回流重绘CSS属性
  • 重绘回流的影响范围限制在单独的图层(layers)之内
  • 图层合成过程中消耗很大页面性能,这时候需要平衡考虑重绘回流的性能消耗

实战优化点总结

  • translate替代top属性

    • top会触发layout,但translate不会
  • opacity代替visibility

    • opacity不会触发重绘也不会触发回流,只是改变图层alpha值,但是必须要将这个图片独立出一个图层
    • visibility会触发重绘
  • 不要一条一条的修改DOM的样式,预先定义好class,然后修改DOMclassName
  • 把DOM离线后修改,比如:先把DOMdisplay:none(有一次reflow),然后你修改100次,然后再把它显示出来
  • 不要把DOM节点的属性值放在一个循环里当成循环的变量

    • offsetHeightoffsetWidth每次都要刷新缓冲区,缓冲机制被破坏
    • 先用变量存储下来
  • 不要使用table布局,可能很小的一个小改动会造成整个table的重新布局

    • div只会影响后续样式的布局
  • 动画实现的速度的选择

    • 选择合适的动画速度
    • 根据performance量化性能优化
  • 对于动画新建图层

    • 启用gpu硬件加速(并行运算),gpu加速意味着数据需要从cpu走总线到gpu传输,需要考虑传输损耗.

      • transform:translateZ(0)
      • transform:translate3D(0)

浏览器存储

cookies

多种浏览器存储方式并存,如何选择?

  • 因为http请求无状态,所以需要cookie去维持客户端状态
  • cookie的生成方式:

    • http-->response header-->set-cookie
    • js中可以通过document.cookie可以读写cookie
    • cookie的使用用处:

      • 用于浏览器端和服务器端的交互(用户状态)
      • 客户端自身数据的存储
  • expire:过期时间
  • cookie的限制:

    • 作为浏览器存储,大小4kb左右
    • 需要设置过期时间 expire
  • 重要属性:httponly 不支持js读写(防止收到模拟请求攻击)
  • 不太作为存储方案而是用于维护客户关系
  • 优化点:cookie中在相关域名下面

    • cdn的流量损耗
    • 解决方案:cdn的域名和主站域名要分开

localStorage

localstorage

  • HTML5设计出来专门用于浏览器存储的
  • 大小为5M左右
  • 仅在客户端使用,不和服务端进行通信
  • 接口封装较好
  • 浏览器本地缓存方案

sessionstorage

  • 会话级别的浏览器存储
  • 大小为5M左右
  • 仅在客户端使用,不和服务器端进行通信
  • 接口封装较好
  • 对于表单信息的维护

indexedDB

  • IndexedDB是一种低级API,用于客户端存储大量结构化数据。该API使用索引来实现对该数据的高性能搜索。虽然Web
  • Storage对于存储叫少量的数据很管用,但对于存储更大量的结构化数据来说,这种方法不太有用。IndexedDB提供了一个解决方案。

为应用创建离线版本

  • cdn域名不要带cookie
  • localstorage存库、图片

cookie种在主站下,二级域名也会携带这个域名,造成流量的浪费

Service Worker产生的意义

PWAService Worker

  • PWA(Progressive Web Apps)是一种Web App新模型,并不是具体指某一种前言的技术或者某一个单一的知识点,我们从英文缩写来看就能看出来,这是一个渐进式的Web App,是通过一系列新的Web特性,配合优秀的UI交互设计,逐步增强Web App的用户体验

PWAService worker

chrome 插件 lighthouse

检测是不是一个渐进式web app
  • 当前手机在弱网环境下能不能加载出来
  • 离线环境下能不能加载出来
特点
  • 可靠:没有网络的环境中也能提供基本的页面访问,而不会出现“未连接到互联网”的页面
  • 快速:针对网页渲染及网络数据访问有较好的优化
  • 融入(Engaging):应用可以被增加到手机桌面,并且和普通应用一样有全屏、推送等特性

service worker

service worker是一个脚本,浏览器独立于当前页面,将其在后台运行,为实现一些不依赖页面的或者用户交互的特性打开了一扇大门。在未来这些特性将包括消息推送,背景后台同步,geofencing(地理围栏定位),但他将推出的第一个首要的特性,就是拦截和处理网络请求的能力,包括以编程方式来管理被缓存的响应。

案例分析

Service Worker学习与实践

了解servie worker

chrome://serviceworker-internals/

chrome://inspect/#service-worker/

service worker网络拦截能力,存储Cache Storage,实现离线应用

indexedDB

callback && callback()写法
相当于 
if(callback){
   callback();
}

cookiesessionlocalStoragesessionStorage基本操作

indexedDB基本操作

object store:对象存储
本身就是结构化存储
 function openDB(name, callback) {
            //建立打开indexdb  indexedDB.open
            var request = window.indexedDB.open(name)
            request.onerror = function(e) {
                console.log('on indexedDB error')
            }
            request.onsuccess = function(e) {
                    myDB.db = e.target.result
                    callback && callback()
                }
                //from no database to first version,first version to second version...
            request.onupgradeneeded = function() {
                console.log('created')
                var store = request.result.createObjectStore('books', {
                    keyPath: 'isbn'
                })
                console.log(store)
                var titleIndex = store.createIndex('by_title', 'title', {
                    unique: true
                })
                var authorIndex = store.createIndex('by_author', 'author')

                store.put({
                    title: 'quarry memories',
                    author: 'fred',
                    isbn: 123456
                })
                store.put({
                    title: 'dafd memories',
                    author: 'frdfaded',
                    isbn: 12345
                })
                store.put({
                    title: 'dafd medafdadmories',
                    author: 'frdfdsafdafded',
                    isbn: 12345434
                })
            }
        }
        var myDB = {
            name: 'tesDB',
            version: '2.0.1',
            db: null
        }

        function addData(db, storeName) {

        }

        openDB(myDB.name, function() {
            // myDB.db = e.target.result
            // window.indexedDB.deleteDatabase(myDB.name)
        });

        //删除indexedDB

indexDB事务

transcationobject store建立关联关系来操作object store
建立之初可以配置

 var transcation = db.transcation('books', 'readwrite')
 var store = transcation.objectStore('books')

 var data =store.get(34314)
 store.delete(2334)
 store.add({
     title: 'dafd medafdadmories',
     author: 'frdfdsafdafded',
     isbn: 12345434
 })

Service Worker离线应用

serviceworker需要https协议

如何实现ServiceWorker与主页面之间的通信

lavas

缓存

期望大规模数据能自动化缓存,而不是手动进行缓存,需要浏览器端和服务器端协商一种缓存机制

  • Cache-Control所控制的缓存策略
  • last-modified 和 etage以及整个服务端浏览器端的缓存流程
  • 基于node实践以上缓存方式

httpheader

可缓存性

  • public:表明响应可以被任何对象(包括:发送请求的客户端,代理服务器,等等)缓存。
  • private:表明响应只能被单个用户缓存,不能作为共享缓存(即代理服务器不能缓存它)。
  • no-cache:强制所有缓存了该响应的缓存用户,在使用已存储的缓存数据前,发送带验证器的请求到原始服务器
  • only-if-cached:表明如果缓存存在,只使用缓存,无论原始服务器数据是否有更新

到期

  • max-age=<seconds>:设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。与 Expires相反,时间是相对于请求的时间。
  • s-maxage=<seconds>:覆盖max-age 或者 Expires 头,但是仅适用于共享缓存(比如各个代理),并且私有缓存中它被忽略。cdn缓存
  • max-stale[=<seconds>]

表明客户端愿意接收一个已经过期的资源。 可选的设置一个时间(单位秒),表示响应不能超过的过时时间。

  • min-fresh=<seconds>

表示客户端希望在指定的时间内获取最新的响应。

重新验证重新加载

重新验证
  • must-revalidate:缓存必须在使用之前验证旧资源的状态,并且不可使用过期资源。
  • proxy-revalidate:与must-revalidate作用相同,但它仅适用于共享缓存(例如代理),并被私有缓存忽略。
  • immutable :表示响应正文不会随时间而改变。资源(如果未过期)在服务器上不发生改变,因此客户端不应发送重新验证请求头(例如If-None-MatchIf-Modified-Since)来检查更新,即使用户显式地刷新页面。在Firefox中,immutable只能被用在 https:// transactions.
重新加载
  • no-store:缓存不应存储有关客户端请求或服务器响应的任何内容。
  • no-transform:不得对资源进行转换或转变。Content-Encoding, Content-Range, Content-TypeHTTP头不能由代理修改。例如,非透明代理可以对图像格式进行转换,以便节省缓存空间或者减少缓慢链路上的流量。 no-transform指令不允许这样做。

Expires

  • 缓存过期时间,用来指定资源到期的时间,是服务器端的时间点
  • 告诉浏览器在过期时间前浏览器可以直接从浏览器缓存中存取数据,而无需再次请求
  • expireshttp1.0的时候的
  • http1.1时候,我们希望cache的管理统一进行,max-age优先级高于expires,当有max-age在的时候expires可能就会被忽略。
  • 如果没有设置cache-control时候会使用expires

Last-modifiedIf-Modified-since

  • 基于客户端和服务器端协商的缓存机制
  • last-modified --> response header

    if-modified-since --> request header
  • 需要与cache-control共同使用
last-modified有什么缺点?
  • 某些服务端不能获取精确的修改时间
  • 文件修改时间改了,但文件的内容却没有变

EtagIf-none-match

  • 文件内容的hash值
  • etag -->reponse header

    if-none-match -->request header
  • 需要与cache-control共同使用

好处:

  • if-modified-since更加准确
  • 优先级比etage更高

流程图


enter image description here

服务端性能优化

服务端用的node.js因为和前端用的同一种语言,可以利用服务端运算能力来进行相关的运算而减少前端的运算

  • vue渲染遇到的问题
  • vue-ssr和原理和引用

vue渲染面临的问题

    先加载vue.js
=>  执行vue.js代码
=>  生成html
以前没有前端框架时,
  • jsp/php在服务端进行数据的填充,发送给客户端就是已经填充好数据`的html
  • 使用jQuery异步加载数据
  • 使用ReactVue前端框架

    • 代价:需要框架全部加载完,才能把页面渲染出来,页面的首屏性能不好

多层次的优化方案

  • 构建层的模板编译。runtime,compile拆开,构建层做模板编译工作。webpack构建时候,统一,直接编译成runtime可以执行的代码
  • 数据无关的prerender的方式
  • 服务端渲染

查看原文

naice 收藏了文章 · 2019-03-01

IntersectionObserve初试

IntersectionObserve这个API,可能知道的人并不多(我也是最近才知道...),这个API可以很方便的监听元素是否进入了可视区域。

<style>
* {
  margin: 0;
  padding: 0;
}

.test {
  width: 200px;
  height: 1000px;
  background: orange;
}

.box {
  width: 150px;
  height: 150px;
  margin: 50px;
  background: red;
}
</style>
<div class="test">test</div>
<div class="box">box</div>

上图代码中,.box元素目前并不在可视区域(viewport)内,如何监听它进入可视区域内?

传统做法是:监听scroll事件,实时计算.box距离viewport的top值:

const box = document.querySelector('.box');
const viewHeight = document.documentElement.clientHeight;

window.addEventListener('scroll', () => {
  const top = box.getBoundingClientRect().top;
  
  if (top < viewHeight) {
    console.log('进入可视区域');
  }
});

然而scroll事件会频繁触发,因此我们还需要手动节流。

使用IntersectionObserve就非常方便了:

const box = document.querySelector('.box');
const intersectionObserver = new IntersectionObserver((entries) => {
  entries.forEach((item) => {
    if (item.isIntersecting) {
      console.log('进入可视区域');
    }
  })
});
intersectionObserver.observe(box);

IntersectionObserver API是异步的,不随着目标元素的滚动同步触发,所以不会有性能问题。

IntersectionObserver构造函数

const io = new IntersectionObserver((entries) => {
  console.log(entries);
});

io.observe(dom);

调用IntersectionObserver时,需要给它传一个回调函数。当监听的dom元素进入可视区域或者从可视区域离开时,回调函数就会被调用。

注意: 第一次调用new IntersectionObserver时,callback函数会先调用一次,即使元素未进入可视区域。

构造函数的返回值是一个观察器实例。实例的observe方法可以指定观察哪个 DOM 节点。

// 开始观察
io.observe(document.getElementById('example'));

// 停止观察
io.unobserve(element);

// 关闭观察器
io.disconnect();

IntersectionObserverEntry对象

callback函数被调用时,会传给它一个数组,这个数组里的每个对象就是当前进入可视区域或者离开可视区域的对象(IntersectionObserverEntry对象)

这个对象有很多属性,其中最常用的属性是:

  • target: 被观察的目标元素,是一个 DOM 节点对象
  • isIntersecting: 是否进入可视区域
  • intersectionRatio: 相交区域和目标元素的比例值,进入可视区域,值大于0,否则等于0

options

调用IntersectionObserver时,除了传一个回调函数,还可以传入一个option对象,配置如下属性:

  • threshold: 决定了什么时候触发回调函数。它是一个数组,每个成员都是一个门槛值,默认为[0],即交叉比例(intersectionRatio)达到0时触发回调函数。用户可以自定义这个数组。比如,[0, 0.25, 0.5, 0.75, 1]就表示当目标元素 0%、25%、50%、75%、100% 可见时,会触发回调函数。
  • root: 用于观察的根元素,默认是浏览器的视口,也可以指定具体元素,指定元素的时候用于观察的元素必须是指定元素的子元素
  • rootMargin: 用来扩大或者缩小视窗的的大小,使用css的定义方法,10px 10px 30px 20px表示top、right、bottom 和 left的值
const io = new IntersectionObserver((entries) => {
  console.log(entries);
}, {
  threshold: [0, 0.5],
  root: document.querySelector('.container'),
  rootMargin: "10px 10px 30px 20px",
});

懒加载

图片懒加载的原理就是:给img标签一个自定义属性,用来记录真正的图片地址。默认img标签只加载一个占位符。当图片进入可视区域时,再把img的src属性更换成真正的图片地址。

<div>
  <img data-original="/empty.jpg" data-data-original="/img/1.jpg" />
  <img data-original="/empty.jpg" data-data-original="/img/2.jpg" />
</div>
const intersectionObserver = new IntersectionObserver((entries) => {
    entries.forEach((item) => {
        if (item.isIntersecting) {
            item.target.src = item.target.dataset.src;
            // 替换成功后,停止观察该dom
            intersectionObserver.unobserve(item.target);
        }
    })
  }, {
      rootMargin: "150px 0px" // 提前150px进入可视区域时就加载图片,提高用户体验
    });

const imgs = document.querySelectorAll('[data-src]');
imgs.forEach((item) => {
    intersectionObserver.observe(item)
});

打点上报

前端页面经常有上报数据的需求,比如统计页面上的某些元素是否被用户查看,点击。这时,我们就可以封装一个Log组件,用于当元素进入可视区域时,就上报数据。
以React为例,我们希望:被Log组件包裹的元素,进入可视区域后,就向后台发送{ appid: 1234, type: 'news'}数据

<Log
  className="container"
  log={{ appid: 1234, type: 'news'}}
  style={{ marginTop: '1400px' }}
  onClick={() => console.log('log')}
>
  <div className="news" onClick={() => console.log('news')}>
    <p>其他内容</p>
  </div>
</Log>
import React, { Component } from 'react';
import PropTypes from 'prop-types';

class Log extends Component {
  static propTypes = {
    log: PropTypes.object
  };

  constructor(props) {
    super(props);
    this.io = null;
    this.ref = React.createRef();
  }

  componentDidMount() {
    this.io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          console.log('进入可视区域,将要发送log数据', this.props.log);
          sendLog(this.props.log);
          this.io.disconnect();
        }
      }
    );

    this.io.observe(this.ref.current);
  }

  componentWillUnmount() {
    this.io && this.io.disconnect();
  }

  render() {
    // 把log属性去掉,否则log属性也会渲染在div上 
    // <div log="[object Object]"></div>
    const { log, ...props } = this.props;
    return (
      <div ref={this.ref} {...props}>
        {this.props.children}
      </div>
    )
  }
}

export default Log

兼容性

safari并不支持该API,因此为了兼容主流浏览器,我们需要引入polyfill
intersection-observer

查看原文

naice 收藏了文章 · 2019-03-01

vue组件之间8种组件通信方式总结

对于vue来说,组件之间的消息传递是非常重要的,下面是我对组件之间消息传递的各种方式的总结,总共有8种方式。

1.props和$emit

父组件向子组件传递数据是通过prop传递的,子组件传递数据给父组件是通过$emit触发事件来做到的。

Vue.component('child',{
        data(){
            return {
                mymessage:this.message
            }
        },
        template:`
            <div>
                <input type="text" v-model="mymessage" @input="passData(mymessage)"> </div>
        `,
        props:['message'],//得到父组件传递过来的数据
        methods:{
            passData(val){
                //触发父组件中的事件
                this.$emit('getChildData',val)
            }
        }
    })
    Vue.component('parent',{
        template:`
            <div>
                <p>this is parent compoent!</p>
                <child :message="message" v-on:getChildData="getChildData"></child>
            </div>
        `,
        data(){
            return {
                message:'hello'
            }
        },
        methods:{
            //执行子组件触发的事件
            getChildData(val){
                console.log(val)
            }
        }
    })
    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <parent></parent>
            </div>
        `
    })

在上面的例子中,有父组件parent和子组件child。
1).父组件传递了message数据给子组件,并且通过v-on绑定了一个getChildData事件来监听子组件的触发事件;
2).子组件通过props得到相关的message数据,最后通过this.$emit触发了getChildData事件。

2.$attrs和$listeners

第一种方式处理父子组件之间的数据传输有一个问题:如果父组件A下面有子组件B,组件B下面有组件C,这时如果组件A想传递数据给组件C怎么办呢?
如果采用第一种方法,我们必须让组件A通过prop传递消息给组件B,组件B在通过prop传递消息给组件C;要是组件A和组件C之间有更多的组件,那采用这种方式就很复杂了。Vue 2.4开始提供了$attrs和$listeners来解决这个问题,能够让组件A之间传递消息给组件C。

Vue.component('C',{
        template:`
            <div>
                <input type="text" v-model="$attrs.messagec" @input="passCData($attrs.messagec)"> </div>
        `,

        methods:{
            passCData(val){
                //触发父组件A中的事件
                this.$emit('getCData',val)
            }
        }
    })

    Vue.component('B',{
        data(){
            return {
                mymessage:this.message
            }
        },
        template:`
            <div>
                <input type="text" v-model="mymessage" @input="passData(mymessage)"> 
                <!-- C组件中能直接触发getCData的原因在于 B组件调用C组件时 使用 v-on 绑定了$listeners 属性 -->
                <!-- 通过v-bind 绑定$attrs属性,C组件可以直接获取到A组件中传递下来的props(除了B组件中props声明的) -->
                <C v-bind="$attrs" v-on="$listeners"></C>
            </div>
        `,
        props:['message'],//得到父组件传递过来的数据
        methods:{
            passData(val){
                //触发父组件中的事件
                this.$emit('getChildData',val)
            }
        }
    })
    Vue.component('A',{
        template:`
            <div>
                <p>this is parent compoent!</p>
                <B :messagec="messagec" :message="message" v-on:getCData="getCData" v-on:getChildData="getChildData(message)"></B>
            </div>
        `,
        data(){
            return {
                message:'hello',
                messagec:'hello c' //传递给c组件的数据
            }
        },
        methods:{
            getChildData(val){
                console.log('这是来自B组件的数据')
            },
            //执行C子组件触发的事件
            getCData(val){
                console.log("这是来自C组件的数据:"+val)
            }
        }
    })
    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <A></A>
            </div>
        `
    })

3.中央事件总线

上面两种方式处理的都是父子组件之间的数据传递,而如果两个组件不是父子关系呢?这种情况下可以使用中央事件总线的方式。新建一个Vue事件bus对象,然后通过bus.$emit触发事件,bus.$on监听触发的事件。

Vue.component('brother1',{
        data(){
            return {
                mymessage:'hello brother1'
            }
        },
        template:`
            <div>
                <p>this is brother1 compoent!</p>
                <input type="text" v-model="mymessage" @input="passData(mymessage)"> 

            </div>
        `,
        methods:{
            passData(val){
                //触发全局事件globalEvent
                bus.$emit('globalEvent',val)

            }
        }
    })
    Vue.component('brother2',{
        template:`
            <div>
                <p>this is brother2 compoent!</p>
                <p>brother1传递过来的数据:{{brothermessage}}</p>
            </div>
        `,
        data(){
            return {
                mymessage:'hello brother2',

                brothermessage:''
            }
        },
        mounted(){
            //绑定全局事件globalEvent
            bus.$on('globalEvent',(val)=>{
                this.brothermessage=val;
            })
        }
    })
    //中央事件总线
    var bus=new Vue();

    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <brother1></brother1>
                <brother2></brother2>
            </div>
        `
    })

4.provide和inject

父组件中通过provider来提供变量,然后在子组件中通过inject来注入变量。不论子组件有多深,只要调用了inject那么就可以注入provider中的数据。而不是局限于只能从当前父组件的prop属性来获取数据,只要在父组件的生命周期内,子组件都可以调用。

Vue.component('child',{
        inject:['for'],//得到父组件传递过来的数据
        data(){
            return {
                mymessage:this.for
            }
        },
        template:`
            <div>
                <input type="tet" v-model="mymessage"> 
            </div>
    })
    Vue.component('parent',{
        template:`
            <div>
                <p>this is parent compoent!</p>
                <child></child>
            </div>
        `,
        provide:{
            for:'test'
        },
        data(){
            return {
                message:'hello'
            }
        }
    })
    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <parent></parent>
            </div>
        `
    })

5.v-model

父组件通过v-model传递值给子组件时,会自动传递一个value的prop属性,在子组件中通过this.$emit(‘input’,val)自动修改v-model绑定的值

Vue.component('child',{
        props:{
            value:String, //v-model会自动传递一个字段为value的prop属性
        },
        data(){
            return {
                mymessage:this.value
            }
        },
        methods:{
            changeValue(){
                this.$emit('input',this.mymessage);//通过如此调用可以改变父组件上v-model绑定的值
            }
        },
        template:`
            <div>
                <input type="text" v-model="mymessage" @change="changeValue"> 
            </div>
    })
    Vue.component('parent',{
        template:`
            <div>
                <p>this is parent compoent!</p>
                <p>{{message}}</p>
                <child v-model="message"></child>
            </div>
        `,
        data(){
            return {
                message:'hello'
            }
        }
    })
    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <parent></parent>
            </div>
        `
    })

6.$parent和$children

Vue.component('child',{
        props:{
            value:String, //v-model会自动传递一个字段为value的prop属性
        },
        data(){
            return {
                mymessage:this.value
            }
        },
        methods:{
            changeValue(){
                this.$parent.message = this.mymessage;//通过如此调用可以改变父组件的值
            }
        },
        template:`
            <div>
                <input type="text" v-model="mymessage" @change="changeValue"> 
            </div>
    })
    Vue.component('parent',{
        template:`
            <div>
                <p>this is parent compoent!</p>
                <button @click="changeChildValue">test</button >
                <child></child>
            </div>
        `,
        methods:{
            changeChildValue(){
                this.$children[0].mymessage = 'hello';
            }
        },
        data(){
            return {
                message:'hello'
            }
        }
    })
    var app=new Vue({
        el:'#app',
        template:`
            <div>
                <parent></parent>
            </div>
        `
    })

7.boradcast和dispatch

vue1.0中提供了这种方式,但vue2.0中没有,但很多开源软件都自己封装了这种方式,比如min ui、element ui和iview等。
比如如下代码,一般都作为一个mixins去使用, broadcast是向特定的父组件,触发事件,dispatch是向特定的子组件触发事件,本质上这种方式还是on和on和emit的封装,但在一些基础组件中却很实用。

function broadcast(componentName, eventName, params) {
  this.$children.forEach(child => {
    var name = child.$options.componentName;

    if (name === componentName) {
      child.$emit.apply(child, [eventName].concat(params));
    } else {
      broadcast.apply(child, [componentName, eventName].concat(params));
    }
  });
}
export default {
  methods: {
    dispatch(componentName, eventName, params) {
      var parent = this.$parent;
      var name = parent.$options.componentName;
      while (parent && (!name || name !== componentName)) {
        parent = parent.$parent;

        if (parent) {
          name = parent.$options.componentName;
        }
      }
      if (parent) {
        parent.$emit.apply(parent, [eventName].concat(params));
      }
    },
    broadcast(componentName, eventName, params) {
      broadcast.call(this, componentName, eventName, params);
    }
  }
};

8.vuex处理组件之间的数据交互

如果业务逻辑复杂,很多组件之间需要同时处理一些公共的数据,这个时候才有上面这一些方法可能不利于项目的维护,vuex的做法就是将这一些公共的数据抽离出来,然后其他组件就可以对这个公共数据进行读写操作,这样达到了解耦的目的。
详情可参考:https://vuex.vuejs.org/zh-cn/

查看原文

naice 发布了文章 · 2019-02-28

vuex实现及简略解析

大家都知道vuexvue的一个状态管理器,它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。先看看vuex下面的工作流程图


通过官方文档提供的流程图我们知道,vuex的工作流程,

  • 1、数据从state中渲染到页面;
  • 2、在页面通过dispatch来触发action
  • 3、action通过调用commit,来触发mutation
  • 4、mutation来更改数据,数据变更之后会触发dep对象的notify,通知所有Watcher对象去修改对应视图(vue的双向数据绑定原理)。

使用vuex

理解vuex的工作流程我们就看看vuexvue中是怎么使用的。

首先用vue-cli创建一个项目工程,如下图,选择vuex,然后就是一路的回车键

安装好之后,就有一个带有vuexvue项目了。

进入目录然后看到,src/store.js,在里面加了一个状态{count: 100},如下

import Vue from 'vue'
import Vuex from 'vuex' // 引入vuex

Vue.use(Vuex) // 使用插件

export default new Vuex.Store({
  state: {
    count: 100 // 加一个状态
  },
  getter: {
  
  },
  mutations: {
  
  },
  actions: {
  
  }
})

最后在App.vue文件里面使用上这个状态,如下

<template>
  <div id="app">
    这里是stort------->{{this.$store.state.count}}
  </div>
</template>

<script>
export default {
  name: 'app'
}
</script>

<style>
</style>

项目跑起来就会看到页面上看到,页面上会有100了,如下图

到这里我们使用vuex创建了一个store,并且在我们的App组件视图中使用,但是我们会有一些列的疑问。

  • store是如何被使用到各个组件上的??
  • 为什么state的数据是双向绑定的??
  • 在组件中为什么用this.$store.dispch可以触发storeactions??
  • 在组件中为什么用this.$store.commit可以触发storemutations??
  • ....等等等等

带着一堆问题,我们来自己实现一个vuex,来理解vuex的工作原理。

安装并使用store

src下新建一个vuex.js文件,然后代码如下

'use strict'

let Vue = null

class Store {
  constructor (options) {
    let { state, getters, actions, mutations } = options
  }
}
// Vue.use(Vuex)
const install = _Vue => {
  // 避免vuex重复安装
  if (Vue === _Vue) return
  Vue = _Vue
  Vue.mixin({
    // 通过mixins让每个组件实例化的时候都会执行下面的beforeCreate
    beforeCreate () {
      // 只有跟节点才有store配置,所以这里只走一次
      if (this.$options && this.$options.store) {
        this.$store = this.$options.store
      } else if (this.$parent && this.$parent.$store) { // 子组件深度优先 父 --> 子---> 孙子
        this.$store = this.$parent.$store
      }
    }
  })
}

export default { install, Store }

然后修改store.js中的引入vuex模块改成自己的vuex.js

import Vuex from './vuex' // 自己创建的vuex文件

在我们的代码中export default { install, Store }导出了一个对象,分别是installStore

install的作用是,当Vue.use(Vuex)就会自动调用install方法,在install方法里面,我们用mixin混入了一个beforeCreate的生命周期的钩子函数,使得当每个组件实例化的时候都会调用这个函数。

beforeCreate中,第一次根组件通过store属性挂载$store,后面子组件调用beforeCreate挂载的$store都会向上找到父级的$store,这样子通过层层向上寻找,让每个组件都挂上了一个$store属性,而这个属性的值就是我们的new Store({...})的实例。如下图

通过层层向上寻找,让每个组件都挂上了一个$store属性

设置state响应数据

通过上面,我们已经从每个组件都通过this.$store来访问到我们的store的实例,下面我们就编写state数据,让其变成双向绑定的数据。下面我们改写store

class Store {
  constructor (options) {
    let { state, getters, actions, mutations } = options // 拿到传进来的参数
    this.getters = {}
    this.mutations = {}
    this.actions = {}
    // vuex的核心就是借用vue的实例,因为vuex的数据更改回更新视图
    this._vm = new Vue({
      data: {
        state
      }
    })
  }
  // 访问state对象时候,就直接返回响应式的数据
  get state() { // Object.defineProperty get 同理
    return this._vm.state
  }
}

传进来的state对象,通过new Vue({data: {state}})的方式,让数据变成响应式的。当访问state对象时候,就直接返回响应式的数据,这样子在App.vue中就可以通过this.$store.state.count拿到state的数据啦,并且是响应式的呢。

编写mutations、actions、getters

上面我们已经设置好state为响应式的数据,这里我们在store.js里面写上mutations、actions、getters,如下

import Vue from 'vue'
import Vuex from './vuex' // 引入我们的自己编写的文件

Vue.use(Vuex) // 安装store
// 实例化store,参数数对象
export default new Vuex.Store({
  state: {
    count : 1000
  },
  getters : {
    newCount (state) {
      return state.count + 100
    }
  },
  mutations: {
    change (state) {
      console.log(state.count)
      state.count += 10
    }
  },
  actions: {
    change ({commit}) {
      // 模拟异步
      setTimeout(() => {
        commit('change')
      }, 1000)
    }
  }
})

配置选项都写好之后,就看到getters对象里面有个newCount函数,mutationsactions对象里面都有个change函数,配置好store之后我们在App.vue就可以写上,dispatchcommit,分别可以触发actionsmutations,代码如下

<template>
  <div id="app">
    这里是store的state------->{{this.$store.state.count}} <br/>
    这里是store的getter------->{{this.$store.getters.newCount}} <br/>
    <button @click="change">点击触发dispach--> actions</button>
    <button @click="change1">点击触发commit---> mutations</button>
  </div>
</template>

<script>
export default {
  name: 'app',
  methods: {
    change () {
      this.$store.dispatch('change') // 触发actions对应的change
    },
    change1 () {
      this.$store.commit('change') // 触发mutations对应的change
    }
  },
  mounted () {
    console.log(this.$store)
  }
}
</script>

数据都配置好之后,我们开始编写store类,在此之前我们先编写一个循环对象工具函数。

const myforEach = (obj, callback) => Object.keys(obj).forEach(key => callback(key, obj[key]))
// 作用:
// 例如{a: '123'}, 把对象的key和value作为参数
// 然后就是函数运行callback(a, '123')

工具函数都准备好了,之后,下面直接县编写gettersmutationsactions的实现

class Store {
  constructor (options) {
    let { state = {}, getters = {}, actions = {}, mutations = {} } = options
    this.getters = {}
    this.mutations = {}
    this.actions = {}
    // vuex的核心就是借用vue的实例,因为vuex的数据更改回更新视图
    this._vm = new Vue({
      data: {
        state
      }
    })
    // 循环getters的对象
    myforEach(getters, (getterName, getterFn) => {
      // 对this.getters对象进行包装,和vue的computed是差不多的
      // 例如 this.getters['newCount'] = fn(state)
      // 执行 this.getters['newCount']()就会返回计算的数据啦
      Object.defineProperty(this.getters, getterName, {
        get: () => getterFn(state)
      })
    })
    // 这里是mutations各个key和值都写到,this.mutations对象上面
    // 执行的时候就是例如:this.mutations['change']()
    myforEach(mutations, (mutationName, mutationsFn) => {
      // this.mutations.change = () => { change(state) }
      this.mutations[mutationName] = () => {
        mutationsFn.call(this, state)
      }
    })
    // 原理同上
    myforEach(actions, (actionName, actionFn) => {
      // this.mutations.change = () => { change(state) }
      this.actions[actionName] = () => {
        actionFn.call(this, this)
      }
    })
    const {commit , dispatch} = this // 先存一份,避免this.commit会覆盖原型上的this.commit
    // 解构 把this绑定好
    // 通过结构的方式也要先调用这类,然后在下面在调用原型的对应函数
    this.commit = type => {
      commit.call(this, type)
    }
    this.dispatch = type => {
      dispatch.call(this, type)
    }
  }
  get state() { // Object.defineProperty 同理
    return this._vm.state
  }
  // commi调用
  commit (type) {
    this.mutations[type]()
  }
  // dispatch调用
  dispatch (type) {
    this.actions[type]()
  }
}

通过上面的,我们可以看出,其实mutationsactions都是把传入的参数,赋值到store实例上的this.mutationsthis.actions对象里面。

当组件中this.$store.commit('change')的时候 其实是调用this.mutations.change(state),就达到了改变数据的效果,actions同理。

getters是通过对Object.defineProperty(this.getters, getterName, {})
对this.getters进行包装当组件中this.$store.getters.newCount其实是调用getters对象里面的newCount(state),然后返回计算结果。就可以显示到界面上了。

大家看看完成后的效果图。

到这里大家应该懂了vuex的内部代码的工作流程了,vuex的一半核心应该在这里了。为什么说一半,因为还有一个核心概念module,也就是vuex的数据的模块化。

vuex数据模块化

由于使用单一状态树,应用的所有状态会集中到一个比较大的对象。当应用变得非常复杂时,store 对象就有可能变得相当臃肿。

为了解决以上问题,Vuex 允许我们将 store 分割成模块(module)。每个模块拥有自己的 state、mutation、action、getter、甚至是嵌套子模块——从上至下进行同样方式的分割

例如下面的store.js

// 实例化store,参数数对象
export default new Vuex.Store({
  modules: {
    // 模块a
    a: {
      state: {
        count: 4000
      },
      actions: {
        change ({state}) {
          state.count += 21
        }
      },
      modules: {
        // 模块b
        b: {
          state: {
            count: 5000
          }
        }
      }
    }
  },
  state: {
    count : 1000
  },
  getters : {
    newCount (state) {
      return state.count + 100
    }
  },
  mutations: {
    change (state) {
      console.log(state.count)
      state.count += 10
    }
  },
  actions: {
    change ({commit}) {
      // 模拟异步
      setTimeout(() => {
        commit('change')
      }, 1000)
    }
  }
})

然后就可以在界面上就可以写上this.$store.state.a.count(显示a模块count)this.$store.state.a.b.count(显示a模块下,b模块的count),这里还有一个要注意的,其实在组件中调用this.$store.dispatch('change')会同时触发,根的actionsa模块actions里面的change函数。

下面我们就直接去实现models的代码,也就是整个vuex的实现代码,

'use strict'

let Vue = null
const myforEach = (obj, callback) => Object.keys(obj).forEach(key => callback(key, obj[key]))

class Store {
  constructor (options) {
    let state = options.state
    this.getters = {}
    this.mutations = {}
    this.actions = {}
    // vuex的核心就是借用vue的实例,因为vuex的数据更改回更新视图
    this._vm = new Vue({
      data: {
        state
      }
    })

    // 把模块之间的关系进行整理, 自己根据用户参数维护了一个对象
    // root._children => a._children => b
    this.modules = new ModulesCollections(options)
    // 无论子模块还是 孙子模块 ,所有的mutations 都是根上的
    // 安装模块
    installModules(this, state, [], this.modules.root)

    // 解构 把this绑定好
    const {commit , dispatch} = this
    // 通过结构的方式也要先调用这类,然后在下面在调用原型的对应函数
    this.commit = type => {
      commit.call(this, type)
    }
    this.dispatch = type => {
      dispatch.call(this, type)
    }
  }
  get state() { // Object.defineProperty 同理
    return this._vm.state
  }
  commit (type) {
    // 因为是数组,所以要遍历执行
    this.mutations[type].forEach(fn => fn())
  }
  dispatch (type) {
    // 因为是数组,所以要遍历执行
    this.actions[type].forEach(fn => fn())
  }
}

class ModulesCollections {
  constructor (options) { // vuex []
    // 注册模块
    this.register([], options)
  }
  register (path, rawModule) {
    // path 是空数组, rawModule 就是个对象
    let newModule = {
      _raw: rawModule, // 对象
      _children: {}, // 把子模块挂载到这里
      state: rawModule.state
    }
    if (path.length === 0) { // 第一次
      this.root = newModule
    } else {
      // [a, b] ==> [a]
      let parent = path.slice(0, -1).reduce((root, current) => {
        return root._children[current]
      }, this.root)
      parent._children[path[path.length - 1]] = newModule
    }
    if (rawModule.modules) {
      // 遍历注册子模块
      myforEach(rawModule.modules, (childName, module) => {
        this.register(path.concat(childName), module)
      })
    }
  }
}

// rootModule {_raw, _children, state }
function installModules (store, rootState, path, rootModule) {
  // rootState.a = {count:200}
  // rootState.a.b = {count: 3000}
  if (path.length > 0) {
    // 根据path找到对应的父级模块
    // 例如 [a] --> path.slice(0, -1) --> []  此时a模块的父级模块是跟模块
    // 例如 [a,b] --> path.slice(0, -1) --> [a]  此时b模块的父级模块是a模块
    let parent = path.slice(0, -1).reduce((root, current) => {
      return root[current]
    }, rootState)
    // 通过Vue.set设置数据双向绑定
    Vue.set(parent, path[path.length - 1], rootModule.state)
  }
  // 设置getter
  if (rootModule._raw.getters) {
    myforEach(rootModule._raw.getters, (getterName, getterFn) => {
      Object.defineProperty(store.getters, getterName, {
        get: () => {
          return getterFn(rootModule.state)
        }
      })
    })
  }
  // 在跟模块设置actions
  if (rootModule._raw.actions) {
    myforEach(rootModule._raw.actions, (actionName, actionsFn) => {
      // 因为同是在根模块设置,子模块也有能相同的key
      // 所有把所有的都放到一个数组里面
      // 就变成了例如 [change, change] , 第一个是跟模块的actions的change,第二个是a模块的actions的change
      let entry = store.actions[actionName] || (store.actions[actionName] = [])
      entry.push(() => {
        const commit = store.commit
        const state = rootModule.state
        actionsFn.call(store, {state, commit})
      })
    })
  }
  // 在跟模块设置mutations, 同理上actions
  if (rootModule._raw.mutations) {
    myforEach(rootModule._raw.mutations, (mutationName, mutationFn) => {
      let entry = store.mutations[mutationName] || (store.mutations[mutationName] = [])
      entry.push(() => {
        mutationFn.call(store, rootModule.state)
      })
    })
  }
  // 递归遍历子节点的设置
  myforEach(rootModule._children, (childName, module) => {
    installModules(store, rootState, path.concat(childName), module)
  })
}

const install = _Vue => {
  // 避免vuex重复安装
  if (Vue === _Vue) return
  Vue = _Vue
  Vue.mixin({
    // 通过mixins让每个组件实例化的时候都会执行下面的beforeCreate
    beforeCreate () {
      // 只有跟节点才有store配置
      if (this.$options && this.$options.store) {
        this.$store = this.$options.store
      } else if (this.$parent && this.$parent.$store) { // 子组件深度优先 父 --> 子---> 孙子
        this.$store = this.$parent.$store
      }
    }
  })
}

export default { install, Store }

看到代码以及注释,主要流程就是根据递归的方式,处理数据,然后根据传进来的配置,进行操作数据。

至此,我们把vuex的代码实现了一遍,在我们App.vue的代码里添加

<template>
  <div id="app">
    这里是store的state------->{{this.$store.state.count}} <br/>
    这里是store的getter------->{{this.$store.getters.newCount}} <br/>
    这里是store的state.a------->{{this.$store.state.a.count}} <br/>
    <button @click="change">点击触发dispach--> actions</button>
    <button @click="change1">点击触发commit---> mutations</button>
  </div>
</template>

最后查看结果。

完结撒花~~~

博客文章地址:https://blog.naice.me/article...

源码地址:https://github.com/naihe138/w...

查看原文

赞 31 收藏 27 评论 1

naice 评论了文章 · 2018-12-21

试着用Proxy 实现一个简单mvvm

Proxy、Reflect的简单概述

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
出自阮一峰老师的ECMAScript 6 入门,详细点击http://es6.ruanyifeng.com/#docs/proxy

例如:

var obj = new Proxy({}, {
  get: function (target, key, receiver) {
    console.log(`getting ${key}!`);
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log(`setting ${key}!`);
    return Reflect.set(target, key, value, receiver);
  }
});

上面代码对一个空对象架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象obj,去读写它的属性,就会得到下面的结果。

obj.count = 1
//  setting count!
++obj.count
//  getting count!
//  setting count!
//  2
var proxy = new Proxy(target, handler);

这里有两个参数,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。

注意,要使得Proxy起作用,必须针对Proxy实例(上例是proxy对象)进行操作,而不是针对目标对象(上例是空对象)进行操作。

Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API

Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

同样也放上阮一峰老师的链接http://es6.ruanyifeng.com/#docs/reflect

初始化结构

看到这里,我就当大家有比较明白Proxy(代理)是做什么用的,然后下面我们看下要做最终的图骗。

看到上面的图片,首先我们新建一个index.html,然后里面的代码是这样子滴。很简单

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>简单版mvvm</title>
</head>
<body>
<div id="app">
    <h1>开发语言:{{language}}</h1>
    <h2>组成部分:</h2>
    <ul>
        <li>{{makeUp.one}}</li>
        <li>{{makeUp.two}}</li>
        <li>{{makeUp.three}}</li>
    </ul>
    <h2>描述:</h2>
    <p>{{describe}}</p>
    <p>计算属性:{{sum}}</p>
    <input placeholder="123" v-module="language" />
</div>
<script>
// 写法和Vue一样
const mvvm = new Mvvm({
    el: '#app',
    data: {
        language: 'Javascript',
        makeUp: {
            one: 'ECMAScript',
            two: '文档对象模型(DOM)',
            three: '浏览器对象模型(BOM)'
        },
        describe: '没什么产品是写不了的',
        a: 1,
        b: 2
    },
    computed: {
        sum() {
        return this.a + this.b
    }
})
</script>
</body>
</html>

看到上面的代码,大概跟vue长得差不多,下面去实现Mvvm这个构造函数

实现Mvvm这个构造函数

首先声明一个Mvvm函数,options当作参数传进来,options就是上面代码的配置,里面有eldatacomputed~~

function Mvvm(options = {}) {
    // 把options 赋值给this.$options
    this.$options = options
    // 把options.data赋值给this._data
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    return this._vm
}

上面Mvvm函数很简单,就是把参数options 赋值给this.$options、把options.data赋值给this._data、然后调用初始化initVm函数,并用call改变this的指向,方便initVm函操作。然后返回一个this._vm,这个是在initVm函数生成的。

下面继续写initVm函数,


function initVm () {
    this._vm = new Proxy(this, {
        // 拦截get
        get: (target, key, receiver) => {
            return this[key] || this._data[key] || this._computed[key]
        },
        // 拦截set
        set: (target, key, value) => {
            return Reflect.set(this._data, key, value)
        }
    })
    return this._vm
}

这个init函数用到Proxy拦截了,this对象,生产Proxy实例的然后赋值给this._vm,最后返回this._vm

上面我们说了,要使得Proxy起作用,必须针对Proxy实例。

在代理里面,拦截了getsetget函数里面,返回this对象的对应的key的值,没有就去this._data对象里面取对应的key,再没有去this._computed对象里面去对应的key值。set函数就是直接返回修改this._data对应key

做好这些各种拦截工作。我们就可以直接从实力上访问到我们相对应的值了。(mvvm使我们第一块代码生成的实例)

mvvm.b // 2
mvvm.a // 1
mvvm.language // "Javascript"

如上图看控制台。可以设置值,可以获取值,但是这不是响应式的。

打开控制台看一下

可以详细的看到。只有_vm这个是proxy,我们需要的是,_data下面所有数据都是有拦截代理的;下面我们就去实现它。

实现所有数据代理拦截

我们首先在Mvvm里面加一个initObserve,如下

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   initObserve.call(this, data) // 初始化data的Observe
    return this._vm
}

initObserve这个函数主要是把,this._data都加上代理。如下


function initObserve(data) {
    this._data = observe(data) // 把所有observe都赋值到 this._data
}

// 分开这个主要是为了下面递归调用
function observe(data) {
    if (!data || typeof data !== 'object') return data // 如果不是对象直接返回值
    return new Observe(data) // 对象调用Observe
}

下面主要实现Observe类

// Observe类
class Observe {
    constructor(data) {
        this.dep = new Dep() // 订阅类,后面会介绍
        for (let key in data) {
            data[key] = observe(data[key]) // 递归调用子对象
        }
        return this.proxy(data)
    }
    proxy(data) {
      let dep = this.dep
      return new Proxy(data, {
        get: (target, key, receiver) => {
          return Reflect.get(target, key, receiver)
        },
        set: (target, key, value) => {
          const result = Reflect.set(target, key, observe(value)) // 对于新添加的对象也要进行添加observe
          return result  
        }
      })
    }
  }

这样子,通过我们层层递归添加proxy,把我们的_data对象都添加一遍,再看一下控制台

很不错,_data也有proxy了,很王祖蓝式的完美。

看到我们的html的界面,都是没有数据的,上面我们把数据都准备好了,下面我们就开始把数据结合到html的界面上。

套数据,实现hmtl界面

先把计算属性这个html注释掉,后面进行实现

<!-- <p>计算属性:{{sum}}</p> -->

然后在Mvvm函数中增加一个编译函数,➕号表示是添加的函数

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   new Compile(this.$options.el, vm) // 添加一个编译函数
    return this._vm
}

上面我们添加了一个Compile的构造函数。把配置的el作为参数传机进来,把生成proxy的实例vm也传进去,这样子我们就可以拿到vm下面的数据,下面我们就去实现它。顺序读注释就可以了,很好理解

// 编译类
class Compile {
    constructor (el, vm) {
        this.vm = vm // 把传进来的vm 存起来,因为这个vm.a = 1 没毛病
        let element = document.querySelector(el) // 拿到 app 节点
        let fragment = document.createDocumentFragment() // 创建fragment代码片段
        fragment.append(element) // 把app节点 添加到 创建fragment代码片段中
        this.replace(fragment) // 套数据函数
        document.body.appendChild(fragment) // 最后添加到body中
    }
    replace(frag) {
        let vm = this.vm // 拿到之前存起来的vm
        // 循环frag.childNodes
        Array.from(frag.childNodes).forEach(node => {
            let txt = node.textContent // 拿到文本 例如:"开发语言:{{language}}"
            let reg = /\{\{(.*?)\}\}/g // 定义匹配正则
            if (node.nodeType === 3 && reg.test(txt)) {
            
                replaceTxt()
                
                function replaceTxt() {
                    // 如果匹配到的话,就替换文本
                    node.textContent = txt.replace(reg, (matched, placeholder) => {
                        return placeholder.split('.').reduce((obj, key) => {
                            return obj[key] // 例如:去vm.makeUp.one对象拿到值
                        }, vm)
                    })
                }
            }
            // 如果还有字节点,并且长度不为0 
            if (node.childNodes && node.childNodes.length) {
                // 直接递归匹配替换
                this.replace(node)
            }
        })
    }
}

上面的编译函数,总之就是一句话,千方百计的把{{xxx}}的占位符通过正则替换成真实的数据。

然后刷新浏览器,铛铛档铛铛档,就出现我们要的数据了。

很好很好,但是我们现在的数据并不是改变了 就发生变化了。还需要订阅发布和watcher来配合,才能做好改变数据就发生变化了。下面我们先实现订阅发布。

实现订阅发布

订阅发布其实是一种常见的程序设计模式,简单直白来说就是:

把函数push到一个数组里面,然后循环数据调用函数。

例如:举个很直白的例子

let arr = [] 
let a = () => {console.log('a')}

arr.push(a) // 订阅a函数
arr.push(a) // 又订阅a函数
arr.push(a) // 双订阅a函数

arr.forEach(fn => fn()) // 发布所有

// 此时会打印三个a

很简单吧。下面我们去实现我们的代码

// 订阅类
class Dep {
    constructor() {
        this.subs = [] // 定义数组
    }
    // 订阅函数
    addSub(sub) {
        this.subs.push(sub)
    }
    // 发布函数
    notify() {
        this.subs.filter(item => typeof item !== 'string').forEach(sub => sub.update())
    }
}

订阅发布是写好了,但是在什么时候订阅,什么时候发布??这时候,我们是在数据获取的时候订阅watcher,然后在数据设置的时候发布watcher,在上面的Observe类里面里面,看➕号的代码。 .

... //省略代码
...
proxy(data) {
    let dep = this.dep
    return new Proxy(data, {
        // 拦截get
        get: (target, prop, receiver) => {
+           if (Dep.target) {
                // 如果之前是push过的,就不用重复push了
                if (!dep.subs.includes(Dep.exp)) {
                    dep.addSub(Dep.exp) // 把Dep.exp。push到sub数组里面,订阅
                    dep.addSub(Dep.target) // 把Dep.target。push到sub数组里面,订阅
                }
+           }
            return Reflect.get(target, prop, receiver)
        },
        // 拦截set
        set: (target, prop, value) => {
            const result = Reflect.set(target, prop, observe(value))
+           dep.notify() // 发布
            return result  
        }
    })
}

上面代码说到,watcher是什么鬼?然后发布里面的sub.update()又是什么鬼??

带着一堆疑问我们来到了watcher

实现watcher

看详细注释

// Watcher类
class Watcher {
    constructor (vm, exp, fn) {
        this.fn = fn // 传进来的fn
        this.vm = vm // 传进来的vm
        this.exp = exp // 传进来的匹配到exp 例如:"language","makeUp.one"
        Dep.exp = exp // 给Dep类挂载一个exp
        Dep.target = this // 给Dep类挂载一个watcher对象,跟新的时候就用到了
        let arr = exp.split('.')
        let val = vm
        arr.forEach(key => {
            val = val[key] // 获取值,这时候会粗发vm.proxy的get()函数,get()里面就添加addSub订阅函数
        })
        Dep.target = null // 添加了订阅之后,把Dep.target清空
    }
    update() {
        // 设置值会触发vm.proxy.set函数,然后调用发布的notify,
        // 最后调用update,update里面继续调用this.fn(val)
        let exp = this.exp
        let arr = exp.split('.')
        let val = this.vm
        arr.forEach(key => {
            val = val[key]
        })
        this.fn(val)
    }
}

Watcher类就是我们要订阅的watcher,里面有回调函数fn,有update函数调用fn,

我们都弄好了。但是在哪里添加watcher呢??如下代码

在Compile里面

...
...
function replaceTxt() {
    node.textContent = txt.replace(reg, (matched, placeholder) => {
+       new Watcher(vm, placeholder, replaceTxt);   // 监听变化,进行匹配替换内容
        return placeholder.split('.').reduce((val, key) => {
            return val[key]
        }, vm)
    })
}

添加好有所的东西了,我们看一下控制台。修改发现果然起作用了。

然后我们回顾一下所有的流程,然后看见古老(我也是别的地方弄来的)的一张图。

帮助理解嘛

响应式的数据我们都已经完成了,下面我们完成一下双向绑定。

实现双向绑定

看到我们html里面有个<input placeholder="123" v-module="language" />v-module绑定了一个language,然后在Compile类里面的replace函数,我们加上

replace(frag) {
    let vm = this.vm
    Array.from(frag.childNodes).forEach(node => {
        let txt = node.textContent
        let reg = /\{\{(.*?)\}\}/g
        // 判断nodeType
+       if (node.nodeType === 1) {
            const nodeAttr = node.attributes // 属性集合
            Array.from(nodeAttr).forEach(item => {
                let name = item.name // 属性名
                let exp = item.value // 属性值
                // 如果属性有 v-
                if (name.includes('v-')){
                    node.value = vm[exp]
                    node.addEventListener('input', e => {
                        // 相当于给this.language赋了一个新值
                        // 而值的改变会调用set,set中又会调用notify,notify中调用watcher的update方法实现了更新操作
                        vm[exp] = e.target.value
                    })
                }
            });
+       }
        ...
        ...
    }
  }

上面的方法就是,让我们的input节点绑定一个input事件,然后当input事件触发的时候,改变我们的值,而值的改变会调用setset中又会调用notifynotify中调用watcherupdate方法实现了更新操作。

然后我们看一下,界面

双向数据绑定我们基本完成了,别忘了,我们上面还有个注释掉的计算属性。

计算属性

先把<p>计算属性:{{sum}}</p>注释去掉,以为上面一开始initVm函数里面,我们加了这个代码return this[key] || this._data[key] || this._computed[key],到这里大家都明白了,只需要把this._computed也加一个watcher就好了。

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
+   initComputed.call(this) // 添加计算函数,改变this指向
    new Compile(this.$options.el, vm)
    return this._vm
}


function initComputed() {
    let vm = this
    let computed = this.$options.computed // 拿到配置的computed
    vm._computed = {}
    if (!computed) return // 没有计算直接返回
    Object.keys(computed).forEach(key => {
        // 相当于把sum里的this指向到this._vm,然后就可以拿到this.a、this、b
        this._computed[key] = computed[key].call(this._vm)
        // 添加新的Watcher
        new Watcher(this._vm, key, val => {
            // 每次设置的时候都会计算
            this._computed[key] = computed[key].call(this._vm)
        })
    })
}

上面的initComputed 就是添加一个watcher,大致流程:

this._vm改变 ---> vm.set() ---> notify() -->update()-->更新界面

最后看看图片

一切似乎没什么毛病~~~~

添加mounted钩子

添加mounted也很简单

// 写法和Vue一样
let mvvm = new Mvvm({
    el: '#app',
    data: {
        ...
        ...
    },
    computed: {
        ...
        ...
    },
    mounted() {
        console.log('i am mounted', this.a)
    }
})

在new Mvvm里面添加mounted,
然后到function Mvvm里面加上

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
    initComputed.call(this)
    new Compile(this.$options.el, vm)
+   mounted.call(this._vm) // 加上mounted,改变指向
    return this._vm
}

// 运行mounted
+ function mounted() {
    let mounted = this.$options.mounted
    mounted && mounted.call(this)
+ }

执行之后会打印出

i am mounted 1

完结~~~~撒花

ps:编译里面的,参考到这个大神的操作。@chenhongdong,谢谢大佬

最后附上,源代码地址,直接下载运行就可以啦。

源码地址:https://github.com/naihe138/proxy-mvvm

预览地址:http://gitblog.naice.me/proxy-mvvm/index.html

查看原文

naice 评论了文章 · 2018-12-21

试着用Proxy 实现一个简单mvvm

Proxy、Reflect的简单概述

Proxy 可以理解成,在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写。Proxy 这个词的原意是代理,用在这里表示由它来“代理”某些操作,可以译为“代理器”。
出自阮一峰老师的ECMAScript 6 入门,详细点击http://es6.ruanyifeng.com/#docs/proxy

例如:

var obj = new Proxy({}, {
  get: function (target, key, receiver) {
    console.log(`getting ${key}!`);
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log(`setting ${key}!`);
    return Reflect.set(target, key, value, receiver);
  }
});

上面代码对一个空对象架设了一层拦截,重定义了属性的读取(get)和设置(set)行为。这里暂时先不解释具体的语法,只看运行结果。对设置了拦截行为的对象obj,去读写它的属性,就会得到下面的结果。

obj.count = 1
//  setting count!
++obj.count
//  getting count!
//  setting count!
//  2
var proxy = new Proxy(target, handler);

这里有两个参数,target参数表示所要拦截的目标对象,handler参数也是一个对象,用来定制拦截行为。

注意,要使得Proxy起作用,必须针对Proxy实例(上例是proxy对象)进行操作,而不是针对目标对象(上例是空对象)进行操作。

Reflect对象与Proxy对象一样,也是 ES6 为了操作对象而提供的新 API

Reflect对象的方法与Proxy对象的方法一一对应,只要是Proxy对象的方法,就能在Reflect对象上找到对应的方法。这就让Proxy对象可以方便地调用对应的Reflect方法,完成默认行为,作为修改行为的基础。也就是说,不管Proxy怎么修改默认行为,你总可以在Reflect上获取默认行为。

同样也放上阮一峰老师的链接http://es6.ruanyifeng.com/#docs/reflect

初始化结构

看到这里,我就当大家有比较明白Proxy(代理)是做什么用的,然后下面我们看下要做最终的图骗。

看到上面的图片,首先我们新建一个index.html,然后里面的代码是这样子滴。很简单

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>简单版mvvm</title>
</head>
<body>
<div id="app">
    <h1>开发语言:{{language}}</h1>
    <h2>组成部分:</h2>
    <ul>
        <li>{{makeUp.one}}</li>
        <li>{{makeUp.two}}</li>
        <li>{{makeUp.three}}</li>
    </ul>
    <h2>描述:</h2>
    <p>{{describe}}</p>
    <p>计算属性:{{sum}}</p>
    <input placeholder="123" v-module="language" />
</div>
<script>
// 写法和Vue一样
const mvvm = new Mvvm({
    el: '#app',
    data: {
        language: 'Javascript',
        makeUp: {
            one: 'ECMAScript',
            two: '文档对象模型(DOM)',
            three: '浏览器对象模型(BOM)'
        },
        describe: '没什么产品是写不了的',
        a: 1,
        b: 2
    },
    computed: {
        sum() {
        return this.a + this.b
    }
})
</script>
</body>
</html>

看到上面的代码,大概跟vue长得差不多,下面去实现Mvvm这个构造函数

实现Mvvm这个构造函数

首先声明一个Mvvm函数,options当作参数传进来,options就是上面代码的配置,里面有eldatacomputed~~

function Mvvm(options = {}) {
    // 把options 赋值给this.$options
    this.$options = options
    // 把options.data赋值给this._data
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    return this._vm
}

上面Mvvm函数很简单,就是把参数options 赋值给this.$options、把options.data赋值给this._data、然后调用初始化initVm函数,并用call改变this的指向,方便initVm函操作。然后返回一个this._vm,这个是在initVm函数生成的。

下面继续写initVm函数,


function initVm () {
    this._vm = new Proxy(this, {
        // 拦截get
        get: (target, key, receiver) => {
            return this[key] || this._data[key] || this._computed[key]
        },
        // 拦截set
        set: (target, key, value) => {
            return Reflect.set(this._data, key, value)
        }
    })
    return this._vm
}

这个init函数用到Proxy拦截了,this对象,生产Proxy实例的然后赋值给this._vm,最后返回this._vm

上面我们说了,要使得Proxy起作用,必须针对Proxy实例。

在代理里面,拦截了getsetget函数里面,返回this对象的对应的key的值,没有就去this._data对象里面取对应的key,再没有去this._computed对象里面去对应的key值。set函数就是直接返回修改this._data对应key

做好这些各种拦截工作。我们就可以直接从实力上访问到我们相对应的值了。(mvvm使我们第一块代码生成的实例)

mvvm.b // 2
mvvm.a // 1
mvvm.language // "Javascript"

如上图看控制台。可以设置值,可以获取值,但是这不是响应式的。

打开控制台看一下

可以详细的看到。只有_vm这个是proxy,我们需要的是,_data下面所有数据都是有拦截代理的;下面我们就去实现它。

实现所有数据代理拦截

我们首先在Mvvm里面加一个initObserve,如下

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   initObserve.call(this, data) // 初始化data的Observe
    return this._vm
}

initObserve这个函数主要是把,this._data都加上代理。如下


function initObserve(data) {
    this._data = observe(data) // 把所有observe都赋值到 this._data
}

// 分开这个主要是为了下面递归调用
function observe(data) {
    if (!data || typeof data !== 'object') return data // 如果不是对象直接返回值
    return new Observe(data) // 对象调用Observe
}

下面主要实现Observe类

// Observe类
class Observe {
    constructor(data) {
        this.dep = new Dep() // 订阅类,后面会介绍
        for (let key in data) {
            data[key] = observe(data[key]) // 递归调用子对象
        }
        return this.proxy(data)
    }
    proxy(data) {
      let dep = this.dep
      return new Proxy(data, {
        get: (target, key, receiver) => {
          return Reflect.get(target, key, receiver)
        },
        set: (target, key, value) => {
          const result = Reflect.set(target, key, observe(value)) // 对于新添加的对象也要进行添加observe
          return result  
        }
      })
    }
  }

这样子,通过我们层层递归添加proxy,把我们的_data对象都添加一遍,再看一下控制台

很不错,_data也有proxy了,很王祖蓝式的完美。

看到我们的html的界面,都是没有数据的,上面我们把数据都准备好了,下面我们就开始把数据结合到html的界面上。

套数据,实现hmtl界面

先把计算属性这个html注释掉,后面进行实现

<!-- <p>计算属性:{{sum}}</p> -->

然后在Mvvm函数中增加一个编译函数,➕号表示是添加的函数

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
+   new Compile(this.$options.el, vm) // 添加一个编译函数
    return this._vm
}

上面我们添加了一个Compile的构造函数。把配置的el作为参数传机进来,把生成proxy的实例vm也传进去,这样子我们就可以拿到vm下面的数据,下面我们就去实现它。顺序读注释就可以了,很好理解

// 编译类
class Compile {
    constructor (el, vm) {
        this.vm = vm // 把传进来的vm 存起来,因为这个vm.a = 1 没毛病
        let element = document.querySelector(el) // 拿到 app 节点
        let fragment = document.createDocumentFragment() // 创建fragment代码片段
        fragment.append(element) // 把app节点 添加到 创建fragment代码片段中
        this.replace(fragment) // 套数据函数
        document.body.appendChild(fragment) // 最后添加到body中
    }
    replace(frag) {
        let vm = this.vm // 拿到之前存起来的vm
        // 循环frag.childNodes
        Array.from(frag.childNodes).forEach(node => {
            let txt = node.textContent // 拿到文本 例如:"开发语言:{{language}}"
            let reg = /\{\{(.*?)\}\}/g // 定义匹配正则
            if (node.nodeType === 3 && reg.test(txt)) {
            
                replaceTxt()
                
                function replaceTxt() {
                    // 如果匹配到的话,就替换文本
                    node.textContent = txt.replace(reg, (matched, placeholder) => {
                        return placeholder.split('.').reduce((obj, key) => {
                            return obj[key] // 例如:去vm.makeUp.one对象拿到值
                        }, vm)
                    })
                }
            }
            // 如果还有字节点,并且长度不为0 
            if (node.childNodes && node.childNodes.length) {
                // 直接递归匹配替换
                this.replace(node)
            }
        })
    }
}

上面的编译函数,总之就是一句话,千方百计的把{{xxx}}的占位符通过正则替换成真实的数据。

然后刷新浏览器,铛铛档铛铛档,就出现我们要的数据了。

很好很好,但是我们现在的数据并不是改变了 就发生变化了。还需要订阅发布和watcher来配合,才能做好改变数据就发生变化了。下面我们先实现订阅发布。

实现订阅发布

订阅发布其实是一种常见的程序设计模式,简单直白来说就是:

把函数push到一个数组里面,然后循环数据调用函数。

例如:举个很直白的例子

let arr = [] 
let a = () => {console.log('a')}

arr.push(a) // 订阅a函数
arr.push(a) // 又订阅a函数
arr.push(a) // 双订阅a函数

arr.forEach(fn => fn()) // 发布所有

// 此时会打印三个a

很简单吧。下面我们去实现我们的代码

// 订阅类
class Dep {
    constructor() {
        this.subs = [] // 定义数组
    }
    // 订阅函数
    addSub(sub) {
        this.subs.push(sub)
    }
    // 发布函数
    notify() {
        this.subs.filter(item => typeof item !== 'string').forEach(sub => sub.update())
    }
}

订阅发布是写好了,但是在什么时候订阅,什么时候发布??这时候,我们是在数据获取的时候订阅watcher,然后在数据设置的时候发布watcher,在上面的Observe类里面里面,看➕号的代码。 .

... //省略代码
...
proxy(data) {
    let dep = this.dep
    return new Proxy(data, {
        // 拦截get
        get: (target, prop, receiver) => {
+           if (Dep.target) {
                // 如果之前是push过的,就不用重复push了
                if (!dep.subs.includes(Dep.exp)) {
                    dep.addSub(Dep.exp) // 把Dep.exp。push到sub数组里面,订阅
                    dep.addSub(Dep.target) // 把Dep.target。push到sub数组里面,订阅
                }
+           }
            return Reflect.get(target, prop, receiver)
        },
        // 拦截set
        set: (target, prop, value) => {
            const result = Reflect.set(target, prop, observe(value))
+           dep.notify() // 发布
            return result  
        }
    })
}

上面代码说到,watcher是什么鬼?然后发布里面的sub.update()又是什么鬼??

带着一堆疑问我们来到了watcher

实现watcher

看详细注释

// Watcher类
class Watcher {
    constructor (vm, exp, fn) {
        this.fn = fn // 传进来的fn
        this.vm = vm // 传进来的vm
        this.exp = exp // 传进来的匹配到exp 例如:"language","makeUp.one"
        Dep.exp = exp // 给Dep类挂载一个exp
        Dep.target = this // 给Dep类挂载一个watcher对象,跟新的时候就用到了
        let arr = exp.split('.')
        let val = vm
        arr.forEach(key => {
            val = val[key] // 获取值,这时候会粗发vm.proxy的get()函数,get()里面就添加addSub订阅函数
        })
        Dep.target = null // 添加了订阅之后,把Dep.target清空
    }
    update() {
        // 设置值会触发vm.proxy.set函数,然后调用发布的notify,
        // 最后调用update,update里面继续调用this.fn(val)
        let exp = this.exp
        let arr = exp.split('.')
        let val = this.vm
        arr.forEach(key => {
            val = val[key]
        })
        this.fn(val)
    }
}

Watcher类就是我们要订阅的watcher,里面有回调函数fn,有update函数调用fn,

我们都弄好了。但是在哪里添加watcher呢??如下代码

在Compile里面

...
...
function replaceTxt() {
    node.textContent = txt.replace(reg, (matched, placeholder) => {
+       new Watcher(vm, placeholder, replaceTxt);   // 监听变化,进行匹配替换内容
        return placeholder.split('.').reduce((val, key) => {
            return val[key]
        }, vm)
    })
}

添加好有所的东西了,我们看一下控制台。修改发现果然起作用了。

然后我们回顾一下所有的流程,然后看见古老(我也是别的地方弄来的)的一张图。

帮助理解嘛

响应式的数据我们都已经完成了,下面我们完成一下双向绑定。

实现双向绑定

看到我们html里面有个<input placeholder="123" v-module="language" />v-module绑定了一个language,然后在Compile类里面的replace函数,我们加上

replace(frag) {
    let vm = this.vm
    Array.from(frag.childNodes).forEach(node => {
        let txt = node.textContent
        let reg = /\{\{(.*?)\}\}/g
        // 判断nodeType
+       if (node.nodeType === 1) {
            const nodeAttr = node.attributes // 属性集合
            Array.from(nodeAttr).forEach(item => {
                let name = item.name // 属性名
                let exp = item.value // 属性值
                // 如果属性有 v-
                if (name.includes('v-')){
                    node.value = vm[exp]
                    node.addEventListener('input', e => {
                        // 相当于给this.language赋了一个新值
                        // 而值的改变会调用set,set中又会调用notify,notify中调用watcher的update方法实现了更新操作
                        vm[exp] = e.target.value
                    })
                }
            });
+       }
        ...
        ...
    }
  }

上面的方法就是,让我们的input节点绑定一个input事件,然后当input事件触发的时候,改变我们的值,而值的改变会调用setset中又会调用notifynotify中调用watcherupdate方法实现了更新操作。

然后我们看一下,界面

双向数据绑定我们基本完成了,别忘了,我们上面还有个注释掉的计算属性。

计算属性

先把<p>计算属性:{{sum}}</p>注释去掉,以为上面一开始initVm函数里面,我们加了这个代码return this[key] || this._data[key] || this._computed[key],到这里大家都明白了,只需要把this._computed也加一个watcher就好了。

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
+   initComputed.call(this) // 添加计算函数,改变this指向
    new Compile(this.$options.el, vm)
    return this._vm
}


function initComputed() {
    let vm = this
    let computed = this.$options.computed // 拿到配置的computed
    vm._computed = {}
    if (!computed) return // 没有计算直接返回
    Object.keys(computed).forEach(key => {
        // 相当于把sum里的this指向到this._vm,然后就可以拿到this.a、this、b
        this._computed[key] = computed[key].call(this._vm)
        // 添加新的Watcher
        new Watcher(this._vm, key, val => {
            // 每次设置的时候都会计算
            this._computed[key] = computed[key].call(this._vm)
        })
    })
}

上面的initComputed 就是添加一个watcher,大致流程:

this._vm改变 ---> vm.set() ---> notify() -->update()-->更新界面

最后看看图片

一切似乎没什么毛病~~~~

添加mounted钩子

添加mounted也很简单

// 写法和Vue一样
let mvvm = new Mvvm({
    el: '#app',
    data: {
        ...
        ...
    },
    computed: {
        ...
        ...
    },
    mounted() {
        console.log('i am mounted', this.a)
    }
})

在new Mvvm里面添加mounted,
然后到function Mvvm里面加上

function Mvvm(options = {}) {
    this.$options = options
    let data = this._data = this.$options.data
    let vm = initVm.call(this)
    initObserve.call(this, data)
    initComputed.call(this)
    new Compile(this.$options.el, vm)
+   mounted.call(this._vm) // 加上mounted,改变指向
    return this._vm
}

// 运行mounted
+ function mounted() {
    let mounted = this.$options.mounted
    mounted && mounted.call(this)
+ }

执行之后会打印出

i am mounted 1

完结~~~~撒花

ps:编译里面的,参考到这个大神的操作。@chenhongdong,谢谢大佬

最后附上,源代码地址,直接下载运行就可以啦。

源码地址:https://github.com/naihe138/proxy-mvvm

预览地址:http://gitblog.naice.me/proxy-mvvm/index.html

查看原文

naice 评论了文章 · 2018-11-10

GraphQL 搭配 Koa 最佳入门实践

代码有更新,点击直接查看

GraphQL一种用为你 API 而生的查询语言,2018已经到来,PWA还没有大量投入生产应用之中就已经火起来了,GraphQL的应用或许也不会太远了。前端的发展的最大一个特点就是变化快,有时候应对各种需求场景的变化,不得不去对接口开发很多版本或者修改。各种业务依赖强大的基础数据平台快速生长,如何高效地为各种业务提供数据支持,是所有人关心的问题。而且现在前端的解决方案是将视图组件化,各个业务线既可以是组件的使用者,也可以是组件的生产者,如果能够将其中通用的内容抽取出来提供给各个业务方反复使用,必然能够节省宝贵的开发时间和开发人力。那么问题来了,前端通过组件实现了跨业务的复用,后端接口如何相应地提高开发效率呢?GraphQL,就是应对复杂场景的一种新思路。

官方解释:

GraphQL 既是一种用于 API 的查询语言也是一个满足你数据查询的运行时。 GraphQL 对你的 API 中的数据提供了一套易于理解的完整描述,使得客户端能够准确地获得它需要的数据,而且没有任何冗余,也让 API 更容易地随着时间推移而演进,还能用于构建强大的开发者工具。

下面介绍一下GraphQL的有哪些好处:

  • 请求你所要的数据不多不少
  • 获取多个资源只用一个请求
  • 自定义接口数据的字段
  • 强大的开发者工具
  • API 演进无需划分版本

本篇文章中将搭配koa实现一个GraphQL查询的例子,逐步从简单kao服务到mongodb的数据插入查询再到GraphQL的使用,
让大家快速看到:

  • 搭建koa搭建一个后台项目
  • 后台路由简单处理方式
  • 利用mongoose简单操作mongodb
  • 掌握GraphQL的入门姿势

项目如下图所示

1、搭建GraphQL工具查询界面。

2、前端用jq发送ajax的使用方式

入门项目我们都已经是预览过了,下面我们动手开发吧!!!

lets do it

首先建立一个项目文件夹,然后在这个项目文件夹新建一个server.js(node服务)、config文件夹mongodb文件夹router文件夹controllers文件夹以及public文件夹(这个主要放前端静态数据展示页面),好啦,项目的结构我们都已经建立好,下面在server.js文件夹里写上

server.js
// 引入模块
import Koa from 'koa'
import KoaStatic from 'koa-static'
import Router from 'koa-router'
import bodyParser from 'koa-bodyparser'


const app = new Koa()
const router = new Router();

// 使用 bodyParser 和 KoaStatic 中间件
app.use(bodyParser());
app.use(KoaStatic(__dirname + '/public'));

// 路由设置test
router.get('/test', (ctx, next) => {
  ctx.body="test page"
});

app
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(4000);

console.log('graphQL server listen port: ' + 4000)

在命令行npm install koa koa-static koa-router koa-bodyparser --save

安装好上面几个模块,

然后运行node server.js,不出什么意外的话,你会发现报如下图的一个error

原因是现在的node版本并没有支持es6的模块引入方式。

放心 我们用神器babel-polyfill转译一下就阔以了。详细的请看阮一峰老师的这篇文章

下面在项目文件夹新建一个start.js,然后在里面写上以下代码:

start.js
require('babel-core/register')({
  'presets': [
    'stage-3',
    ["latest-node", { "target": "current" }]
  ]
})

require('babel-polyfill')
require('./server')

然后 在命令行,运行npm install babel-core babel-polyfill babel-preset-latest-node babel-preset-stage-3 --save-dev安装几个开发模块。

安装完毕之后,在命令行运行 node start.js,之后你的node服务安静的运行起来了。用koa-router中间件做我们项目路由模块的管理,后面会写到router文件夹中统一管理。

打开浏览器,输入localhost:4000/test,你就会发现访问这个路由node服务会返回test page文字。如下图

yeah~~kao服务器基本搭建好之后,下面就是,链接mongodb然后把数据存储到mongodb数据库里面啦。

实现mongodb的基本数据模型

tip:这里我们需要mongodb存储数据以及利用mongoose模块操作mongodb数据库

  • mongodb文件夹新建一个index.jsschema文件夹, 在 schema文件夹文件夹下面新建info.jsstudent.js
  • config文件夹下面建立一个index.js,这个文件主要是放一下配置代码。

又一波文件建立好之后,先在config/index.js下写上链接数据库配置的代码。

config/index.js
export default {
  dbPath: 'mongodb://localhost/graphql'
}

然后在mongodb/index.js下写上链接数据库的代码。

mongodb/index.js
// 引入mongoose模块
import mongoose from 'mongoose'
import config from '../config'

// 同步引入 info model和 studen model
require('./schema/info')
require('./schema/student')

// 链接mongodb
export const database = () => {
  mongoose.set('debug', true)

  mongoose.connect(config.dbPath)

  mongoose.connection.on('disconnected', () => {
    mongoose.connect(config.dbPath)
  })
  mongoose.connection.on('error', err => {
    console.error(err)
  })

  mongoose.connection.on('open', async () => {
    console.log('Connected to MongoDB ', config.dbPath)
  })
}

上面我们我们代码还加载了info.jsstuden.js这两个分别是学生的附加信息和基本信息的数据模型,为什么会分成两个信息表?原因是顺便给大家介绍一下联表查询的基本方法(嘿嘿~~~)

下面我们分别完成这两个数据模型

mongodb/schema/info.js
// 引入mongoose
import mongoose from 'mongoose'

// 
const Schema = mongoose.Schema

// 实例InfoSchema
const InfoSchema = new Schema({
  hobby: [String],
  height: String,
  weight: Number,
  meta: {
    createdAt: {
      type: Date,
      default: Date.now()
    },
    updatedAt: {
      type: Date,
      default: Date.now()
    }
  }
})
// 在保存数据之前跟新日期
InfoSchema.pre('save', function (next) {
  if (this.isNew) {
    this.meta.createdAt = this.meta.updatedAt = Date.now()
  } else {
    this.meta.updatedAt = Date.now()
  }

  next()
})
// 建立Info数据模型
mongoose.model('Info', InfoSchema)

上面的代码就是利用mongoose实现了学生的附加信息的数据模型,用同样的方法我们实现了student数据模型

mongodb/schema/student.js
import mongoose from 'mongoose'

const Schema = mongoose.Schema
const ObjectId = Schema.Types.ObjectId


const StudentSchema = new Schema({
  name: String,
  sex: String,
  age: Number,
  info: {
    type: ObjectId,
    ref: 'Info'
  },
  meta: {
    createdAt: {
      type: Date,
      default: Date.now()
    },
    updatedAt: {
      type: Date,
      default: Date.now()
    }
  }
})

StudentSchema.pre('save', function (next) {
  if (this.isNew) {
    this.meta.createdAt = this.meta.updatedAt = Date.now()
  } else {
    this.meta.updatedAt = Date.now()
  }

  next()
})

mongoose.model('Student', StudentSchema)

实现保存数据的控制器

数据模型都链接好之后,我们就添加一些存储数据的方法,这些方法都写在控制器里面。然后在controler里面新建info.jsstudent.js,这两个文件分别对象,操作info和student数据的控制器,分开写为了方便模块化管理。

  • 实现info数据信息的保存,顺便把查询也先写上去,代码很简单
controlers/info.js
import mongoose from 'mongoose'
const Info = mongoose.model('Info')

// 保存info信息
export const saveInfo = async (ctx, next) => {
  // 获取请求的数据
  const opts = ctx.request.body
  
  const info = new Info(opts)
  const saveInfo = await info.save() // 保存数据
  console.log(saveInfo)
  // 简单判断一下 是否保存成功,然后返回给前端
  if (saveInfo) {
    ctx.body = {
      success: true,
      info: saveInfo
    }
  } else {
    ctx.body = {
      success: false
    }
  }
}

// 获取所有的info数据
export const fetchInfo = async (ctx, next) => {
  const infos = await Info.find({}) // 数据查询

  if (infos.length) {
    ctx.body = {
      success: true,
      info: infos
    }
  } else {
    ctx.body = {
      success: false
    }
  }
}

上面的代码,就是前端用post(路由下面一会在写)请求过来的数据,然后保存到mongodb数据库,在返回给前端保存成功与否的状态。也简单实现了一下,获取全部附加信息的的一个方法。下面我们用同样的道理实现studen数据的保存以及获取。

  • 实现studen数据的保存以及获取
controllers/sdudent.js
import mongoose from 'mongoose'
const Student = mongoose.model('Student')

// 保存学生数据的方法
export const saveStudent = async (ctx, next) => {
  // 获取前端请求的数据
  const opts = ctx.request.body
  
  const student = new Student(opts)
  const saveStudent = await student.save() // 保存数据

  if (saveStudent) {
    ctx.body = {
      success: true,
      student: saveStudent
    }
  } else {
    ctx.body = {
      success: false
    }
  }
}

// 查询所有学生的数据
export const fetchStudent = async (ctx, next) => {
  const students = await Student.find({})

  if (students.length) {
    ctx.body = {
      success: true,
      student: students
    }
  } else {
    ctx.body = {
      success: false
    }
  }
}

// 查询学生的数据以及附加数据
export const fetchStudentDetail = async (ctx, next) => {

  // 利用populate来查询关联info的数据
  const students = await Student.find({}).populate({
    path: 'info',
    select: 'hobby height weight'
  }).exec()

  if (students.length) {
    ctx.body = {
      success: true,
      student: students
    }
  } else {
    ctx.body = {
      success: false
    }
  }
}

实现路由,给前端提供API接口

数据模型和控制器在上面我们都已经是完成了,下面就利用koa-router路由中间件,来实现请求的接口。我们回到server.js,在上面添加一些代码。如下

server.js
import Koa from 'koa'
import KoaStatic from 'koa-static'
import Router from 'koa-router'
import bodyParser from 'koa-bodyparser'

import {database} from './mongodb' // 引入mongodb
import {saveInfo, fetchInfo} from './controllers/info' // 引入info controller
import {saveStudent, fetchStudent, fetchStudentDetail} from './controllers/student' // 引入 student controller

database() // 链接数据库并且初始化数据模型

const app = new Koa()
const router = new Router();

app.use(bodyParser());
app.use(KoaStatic(__dirname + '/public'));

router.get('/test', (ctx, next) => {
  ctx.body="test page"
});

// 设置每一个路由对应的相对的控制器
router.post('/saveinfo', saveInfo)
router.get('/info', fetchInfo)

router.post('/savestudent', saveStudent)
router.get('/student', fetchStudent)
router.get('/studentDetail', fetchStudentDetail)

app
  .use(router.routes())
  .use(router.allowedMethods());

app.listen(4000);

console.log('graphQL server listen port: ' + 4000)

上面的代码,就是做了,引入mongodb设置,info以及student控制器,然后链接数据库,并且设置每一个设置每一个路由对应的我们定义的的控制器。

安装一下mongoose模块 npm install mongoose --save

然后在命令行运行node start,我们服务器运行之后,然后在给info和student添加一些数据。这里是通过postman的谷歌浏览器插件来请求的,如下图所示

yeah~~~保存成功,继续按照步骤多保存几条,然后按照接口查询一下。如下图

嗯,如图都已经查询到我们保存的全部数据,并且全部返回前端了。不错不错。下面继续保存学生数据。

tip: 学生数据保存的时候关联了信息里面的数据哦。所以把id写上去了。

同样的一波操作,我们多保存学生几条信息,然后查询学生信息,如下图所示。

好了 ,数据我们都已经保存好了,铺垫也做了一大把了,下面让我们真正的进入,GrapgQL查询的骚操作吧~~~~

重构路由,配置GraphQL查询界面

别忘了,下面我们建立了一个router文件夹,这个文件夹就是统一管理我们路由的模块,分离了路由个应用服务的模块。在router文件夹新建一个index.js。并且改造一下server.js里面的路由全部复制到router/index.js

顺便在这个路由文件中加入,graphql-server-koa模块,这是koa集成的graphql服务器模块。graphql server是一个社区维护的开源graphql服务器,可以与所有的node.js http服务器框架一起工作:express,connect,hapi,koa和restify。可以点击链接查看详细知识点。

加入graphql-server-koa的路由文件代码如下:

router/index.js

import { graphqlKoa, graphiqlKoa } from 'graphql-server-koa'
import {saveInfo, fetchInfo} from '../controllers/info'
import {saveStudent, fetchStudent, fetchStudentDetail} from '../controllers/student'


const router = require('koa-router')()

router.post('/saveinfo', saveInfo)
      .get('/info', fetchInfo)
      .post('/savestudent', saveStudent)
      .get('/student', fetchStudent)
      .get('/studentDetail', fetchStudentDetail)
      .get('/graphiql', async (ctx, next) => {
        await graphiqlKoa({endpointURL: '/graphql'})(ctx, next)
      })
module.exports = router

之后把server.js的路由代码去掉之后的的代码如下:

server.js

import Koa from 'koa'
import KoaStatic from 'koa-static'
import Router from 'koa-router'
import bodyParser from 'koa-bodyparser'

import {database} from './mongodb'

database()

const GraphqlRouter = require('./router')

const app = new Koa()
const router = new Router();

const port = 4000

app.use(bodyParser());
app.use(KoaStatic(__dirname + '/public'));

router.use('', GraphqlRouter.routes())

app.use(router.routes())
   .use(router.allowedMethods());

app.listen(port);

console.log('GraphQL-demo server listen port: ' + port)

恩,分离之后简洁,明了了很多。然后我们在重新启动node服务。在浏览器地址栏输入http://localhost:4000/graphiql,就会得到下面这个界面。如图:

没错,什么都没有 就是GraphQL查询服务的界面。下面我们把这个GraphQL查询服务完善起来。

编写GraphQL Schema

看一下我们第一张图,我们需要什么数据,在GraphQL查询界面就编写什么字段,就可以查询到了,而后端需要定义好这些数据格式。这就需要我们定义好GraphQL Schema。

首先我们在根目录新建一个graphql文件夹,这个文件夹用于存放管理graphql相关的js文件。然后在graphql文件夹新建一个schema.js

这里我们用到graphql模块,这个模块就是用javascript参考实现graphql查询。向需要详细学习,请使劲戳链接。

我们先写好info的查询方法。然后其他都差不多滴。

graphql/schema.js

// 引入GraphQL各种方法类型

import {
  graphql,
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLID,
  GraphQLList,
  GraphQLNonNull,
  isOutputType
} from 'graphql';

import mongoose from 'mongoose'
const Info = mongoose.model('Info') // 引入Info模块

// 定义日期时间 类型
const objType = new GraphQLObjectType({
  name: 'mete',
  fields: {
    createdAt: {
      type: GraphQLString
    },
    updatedAt: {
      type: GraphQLString
    }
  }
})

// 定义Info的数据类型
let InfoType = new GraphQLObjectType({
  name: 'Info',
  fields: {
    _id: {
      type: GraphQLID
    },
    height: {
      type: GraphQLString
    },
    weight: {
      type: GraphQLString
    },
    hobby: {
      type: new GraphQLList(GraphQLString)
    },
    meta: {
      type: objType
    }
  }
})

// 批量查询
const infos = {
  type: new GraphQLList(InfoType),
  args: {},
  resolve (root, params, options) {
    return Info.find({}).exec() // 数据库查询
  }
}

// 根据id查询单条info数据

const info = {
  type: InfoType,
  // 传进来的参数
  args: {
    id: {
      name: 'id',
      type: new GraphQLNonNull(GraphQLID) // 参数不为空
    }
  },
  resolve (root, params, options) {
    return Info.findOne({_id: params.id}).exec() // 查询单条数据
  }
}

// 导出GraphQLSchema模块

export default new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Queries',
    fields: {
      infos,
      info
    }
  })
})

看代码的时候建议从下往上看~~~~,上面代码所说的就是,建立info和infos的GraphQLSchema,然后定义好数据格式,查询到数据,或者根据参数查询到单条数据,然后返回出去。

写好了info schema之后 我们在配置一下路由,进入router/index.js里面,加入下面几行代码。

router/index.js

import { graphqlKoa, graphiqlKoa } from 'graphql-server-koa'
import {saveInfo, fetchInfo} from '../controllers/info'
import {saveStudent, fetchStudent, fetchStudentDetail} from '../controllers/student'

// 引入schema
import schema from '../graphql/schema'

const router = require('koa-router')()

router.post('/saveinfo', saveInfo)
      .get('/info', fetchInfo)
      .post('/savestudent', saveStudent)
      .get('/student', fetchStudent)
      .get('/studentDetail', fetchStudentDetail)




router.post('/graphql', async (ctx, next) => {
        await graphqlKoa({schema: schema})(ctx, next) // 使用schema
      })
      .get('/graphql', async (ctx, next) => {
        await graphqlKoa({schema: schema})(ctx, next) // 使用schema
      })
      .get('/graphiql', async (ctx, next) => {
        await graphiqlKoa({endpointURL: '/graphql'})(ctx, next) // 重定向到graphiql路由
      })
module.exports = router

详细请看注释,然后被忘记安装好npm install graphql-server-koa graphql --save这两个模块。安装完毕之后,重新运行服务器的node start(你可以使用nodemon来启动本地node服务,免得来回启动。)

然后刷新http://localhost:4000/graphiql,你会发现右边会有查询文档,在左边写上查询方式,如下图

重整Graphql代码结构,完成所有数据查询

现在是我们把schema和type都写到一个文件上面了去了,如果数据多了,字段多了变得特别不好维护以及review,所以我们就把定义type的和schema分离开来,说做就做。

graphql文件夹新建info.jsstuden.js,文件,先把info type 写到info.js代码如下

graphql/info.js
import {
  graphql,
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLID,
  GraphQLList,
  GraphQLNonNull,
  isOutputType
} from 'graphql';

import mongoose from 'mongoose'
const Info = mongoose.model('Info')


const objType = new GraphQLObjectType({
  name: 'mete',
  fields: {
    createdAt: {
      type: GraphQLString
    },
    updatedAt: {
      type: GraphQLString
    }
  }
})

export let InfoType = new GraphQLObjectType({
  name: 'Info',
  fields: {
    _id: {
      type: GraphQLID
    },
    height: {
      type: GraphQLString
    },
    weight: {
      type: GraphQLString
    },
    hobby: {
      type: new GraphQLList(GraphQLString)
    },
    meta: {
      type: objType
    }
  }
})


export const infos = {
  type: new GraphQLList(InfoType),
  args: {},
  resolve (root, params, options) {
    return Info.find({}).exec()
  }
}


export const info = {
  type: InfoType,
  args: {
    id: {
      name: 'id',
      type: new GraphQLNonNull(GraphQLID)
    }
  },
  resolve (root, params, options) {
    return Info.findOne({
      _id: params.id
    }).exec()
  }
}

分离好info type 之后,一鼓作气,我们顺便把studen type 也完成一下,代码如下,原理跟info type 都是相通的,

graphql/student.js

import {
  graphql,
  GraphQLSchema,
  GraphQLObjectType,
  GraphQLString,
  GraphQLID,
  GraphQLList,
  GraphQLNonNull,
  isOutputType,
  GraphQLInt
} from 'graphql';

import mongoose from 'mongoose'

import {InfoType} from './info'
const Student = mongoose.model('Student')


let StudentType = new GraphQLObjectType({
  name: 'Student',
  fields: {
    _id: {
      type: GraphQLID
    },
    name: {
      type: GraphQLString
    },
    sex: {
      type: GraphQLString
    },
    age: {
      type: GraphQLInt
    },
    info: {
      type: InfoType
    }
  }
})


export const student = {
  type: new GraphQLList(StudentType),
  args: {},
  resolve (root, params, options) {
    return Student.find({}).populate({
      path: 'info',
      select: 'hobby height weight'
    }).exec()
  }
}

tips: 上面因为有了联表查询,所以引用了info.js

然后调整一下schema.js的代码,如下:


import {
  GraphQLSchema,
  GraphQLObjectType
} from 'graphql';
// 引入 type 
import {info, infos} from './info'
import {student} from './student'

// 建立 schema
export default new GraphQLSchema({
  query: new GraphQLObjectType({
    name: 'Queries',
    fields: {
      infos,
      info,
      student
    }
  })
})

看到代码是如此的清新脱俗,是不是深感欣慰。好了,graophql数据查询都已经是大概比较完善了。
课程的数据大家可以自己写一下,或者直接到我的github项目里面copy过来我就不一一重复的说了。

下面写一下前端接口是怎么查询的,然后让数据返回浏览器展示到页面的。

前端接口调用

public文件夹下面新建一个index.htmljs文件夹css文件夹,然后在js文件夹建立一个index.js, 在css文件夹建立一个index.css,代码如下

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>GraphQL-demo</title>
  <link rel="stylesheet" href="./css/index.css">
</head>
<body>
  <h1 class="app-title">GraphQL-前端demo</h1>
  <div id="app">
    <div class="course list">
      <h3>课程列表</h3>
      <ul id="courseList">
        <li>暂无数据....</li>
      </ul>
    </div>
    <div class="student list">
      <h3>班级学生列表</h3>
      <ul id="studentList">
        <li>暂无数据....</li>
      </ul>
    </div>
  </div>
  <div class="btnbox">
    <div class="btn" id="btn1">点击常规获取课程列表</div>
    <div class="btn" id="btn2">点击常规获取班级学生列表</div>
    <div class="btn" id="btn3">点击graphQL一次获取所有数据,问你怕不怕?</div>
  </div>
  <div class="toast"></div>
  <script data-original="https://cdn.bootcss.com/jquery/1.10.2/jquery.js"></script>
  <script data-original="./js/index.js"></script>
</body>
</html>

我们主要看js请求方式 代码如下


window.onload = function () {

  $('#btn2').click(function() {
    $.ajax({
      url: '/student',
      data: {},
      success:function (res){
        if (res.success) {
          renderStudent (res.data)
        }
      }
    })
  })

  $('#btn1').click(function() {
    $.ajax({
      url: '/course',
      data: {},
      success:function (res){
        if (res.success) {
          renderCourse(res.data)
        }
      }
    })
  })

  function renderStudent (data) {
    var str = ''
    data.forEach(function(item) {
      str += '<li>姓名:'+item.name+',性别:'+item.sex+',年龄:'+item.age+'</li>'
    })
    $('#studentList').html(str)
  }

  function renderCourse (data) {
    var str = ''
    data.forEach(function(item) {
      str += '<li>课程:'+item.title+',简介:'+item.desc+'</li>'
    })
    $('#courseList').html(str)
  }
  
  // 请求看query参数就可以了,跟查询界面的参数差不多

  $('#btn3').click(function() {
    $.ajax({
      url: '/graphql',
      data: {
        query: `query{
          student{
            _id
            name
            sex
            age
          }
          course{
            title
            desc
          }
        }`
      },
      success:function (res){
        renderStudent (res.data.student)
        renderCourse (res.data.course)
      }
    })
  })
}

css的代码 我就不贴出来啦。大家可以去项目直接拿嘛。

所有东西都已经完成之后,重新启动node服务,然后访问,http://localhost:4000/就会看到如下界面。界面丑,没什么设计美化细胞,求轻喷~~~~

操作点击之后就会想第二张图一样了。

所有效果都出来了,本篇文章也就到此结束了。

附上项目地址: https://github.com/naihe138/GraphQL-demo

ps:喜欢的话丢一个小星星(star)给我嘛

查看原文

naice 回答了问题 · 2018-10-24

JS 正则表达式<img src="" title="" />问题

大佬,举个例子看看??

关注 2 回答 2

naice 提出了问题 · 2018-10-18

用react做ssr,一个列表数据,是放在服务器的redux里面,关闭浏览器,服务器的redux的数据还在么?

大佬们,

问题1、用react做ssr的时候,比如说有一个列表数据,是放在服务器的redux里面,浏览了一会关闭浏览器,服务器的redux的数据还在么?

问题2、用react做ssr的时候,比如说有一个列表数据,是放在服务器的redux里面,同时又1000个人登录请求这个列表,那服务器内存里面是不是存有1000个列表数据(因为不同的人登录不同的账号数据不一样,接口一样)???

关注 1 回答 1

naice 发布了文章 · 2018-10-11

浅析虚拟dom原理并实现

背景

大家都知道,在网页中浏览器资源开销最大便是DOM节点了,DOM很慢并且非常庞大,网页性能问题大多数都是有JavaScript修改DOM所引起的。我们使用Javascript来操纵DOM,操作效率往往很低,由于DOM被表示为树结构,每次DOM中的某些内容都会发生变化,因此对DOM的更改非常快,但更改后的元素,并且它的子项必须经过Reflow / Layout阶段,然后浏览器必须重新绘制更改,这很慢的。因此,回流/重绘的次数越多,您的应用程序就越卡顿。但是,Javascript运行速度很快,虚拟DOM是放在JS 和 HTML中间的一个层。它可以通过新旧DOM的对比,来获取对比之后的差异对象,然后有针对性的把差异部分真正地渲染到页面上,从而减少实际DOM操作,最终达到性能优化的目的。

虚拟dom原理流程

简单概括有三点:

  1. 用JavaScript模拟DOM树,并渲染这个DOM树
  2. 比较新老DOM树,得到比较的差异对象
  3. 把差异对象应用到渲染的DOM树。

下面是流程图:

clipboard.png

下面我们用代码一步步去实现一个流程图

用JavaScript模拟DOM树并渲染到页面上

其实虚拟DOM,就是用JS对象结构的一种映射,下面我们一步步实现这个过程。

我们用JS很容易模拟一个DOM树的结构,例如用这样的一个函数createEl(tagName, props, children)来创建DOM结构。

tagName标签名、props是属性的对象、children是子节点。

然后渲染到页面上,代码如下:

const createEl = (tagName, props, children) => new CreactEl(tagName, props, children)

const vdom = createEl('div', { 'id': 'box' }, [
  createEl('h1', { style: 'color: pink' }, ['I am H1']),
  createEl('ul', {class: 'list'}, [createEl('li', ['#list1']), createEl('li', ['#list2'])]),
  createEl('p', ['I am p'])
])

const rootnode = vdom.render()
document.body.appendChild(rootnode)

通过上面的函数,调用vdom.render()这样子我们就很好的构建了如下所示的一个DOM树,然后渲染到页面上


<div id="box">
  <h1 style="color: pink;">I am H1</h1>
  <ul class="list">
    <li>#list1</li>
    <li>#list2</li>
  </ul>
  <p>I am p</p>
</div>

下面我们看看CreactEl.js代码流程:


import { setAttr } from './utils'
class CreateEl {
  constructor (tagName, props, children) {
    // 当只有两个参数的时候 例如 celement(el, [123])
    if (Array.isArray(props)) {
      children = props
      props = {}
    }
    // tagName, props, children数据保存到this对象上
    this.tagName = tagName
    this.props = props || {}
    this.children = children || []
    this.key = props ? props.key : undefined

    let count = 0
    this.children.forEach(child => {
      if (child instanceof CreateEl) {
        count += child.count
      } else {
        child = '' + child
      }
      count++
    })
    // 给每一个节点设置一个count
    this.count = count
  }
  // 构建一个 dom 树
  render () {
    // 创建dom
    const el = document.createElement(this.tagName)
    const props = this.props
    // 循环所有属性,然后设置属性
    for (let [key, val] of Object.entries(props)) {
      setAttr(el, key, val)
    }
    this.children.forEach(child => {
      // 递归循环 构建tree
      let childEl = (child instanceof CreateEl) ? child.render() : document.createTextNode(child)
      el.appendChild(childEl)
    })
    return el
  }
}

上面render函数的功能是把节点创建好,然后设置节点属性,最后递归创建。这样子我们就得到一个DOM树,然后插入(appendChild)到页面上。

比较新老dom树,得到比较的差异对象

上面,我们已经创建了一个DOM树,然后在创建一个不同的DOM树,然后做比较,得到比较的差异对象。

比较两棵DOM树的差异,是虚拟DOM的最核心部分,这也是人们常说的虚拟DOM的diff算法,两颗完全的树差异比较一个时间复杂度为 O(n^3)。但是在我们的web中很少用到跨层级DOM树的比较,所以一个层级跟一个层级对比,这样算法复杂度就可以达到 O(n)。如下图

clipboard.png

其实在代码中,我们会从根节点开始标志遍历,遍历的时候把每个节点的差异(包括文本不同,属性不同,节点不同)记录保存起来。如下图:

clipboard.png

两个节点之间的差异有总结起来有下面4种

0 直接替换原有节点
1 调整子节点,包括移动、删除等
2 修改节点属性
3 修改节点文本内容

如下面两棵树比较,把差异记录下来。

clipboard.png

主要是简历一个遍历index(看图3),然后从根节点开始比较,比较万之后记录差异对象,继续从左子树比较,记录差异,一直遍历下去。主要流程如下


// 这是比较两个树找到最小移动量的算法是Levenshtein距离,即O(n * m)
// 具体请看 https://www.npmjs.com/package/list-diff2
import listDiff from 'list-diff2'
// 比较两棵树
function diff (oldTree, newTree) {
  // 节点的遍历顺序
  let index = 0
  // 在遍历过程中记录节点的差异
  let patches = {}
  // 深度优先遍历两棵树
  deepTraversal(oldTree, newTree, index, patches)
  // 得到的差异对象返回出去
  return patches
}

function deepTraversal(oldNode, newNode, index, patches) {
  let currentPatch = []
  // ...中间有很多对patches的处理
  // 递归比较子节点是否相同
  diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  if (currentPatch.length) {
    // 那个index节点的差异记录下来
    patches[index] = currentPatch
  }
}

// 子数的diff
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  const diffs = listDiff(oldChildren, newChildren)
  newChildren = diffs.children
  // ...省略记录差异对象
  let leftNode = null
  let currentNodeIndex = index
  oldChildren.forEach((child, i) => {
    const newChild = newChildren[i]
    // index相加
    currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
    // 深度遍历,递归
    deepTraversal(child, newChild, currentNodeIndex, patches)
    // 从左树开始
    leftNode = child
  })
}

然后我们调用完diff(tree, newTree)等到最后的差异对象是这样子的。


{
  "1": [
    {
      "type": 0,
      "node": {
        "tagName": "h3",
        "props": {
          "style": "color: green"
        },
        "children": [
          "I am H1"
        ],
        "count": 1
      }
    }
  ]
  ...
}

key是代表那个节点,这里我们是第二个,也就是h1会改变成h3,还有省略的两个差异对象代码没有贴出来~~

然后看下diff.js的完整代码,如下


import listDiff from 'list-diff2'
// 每个节点有四种变动
export const REPLACE = 0 // 替换原有节点
export const REORDER = 1 // 调整子节点,包括移动、删除等
export const PROPS = 2 // 修改节点属性
export const TEXT = 3 // 修改节点文本内容

export function diff (oldTree, newTree) {
  // 节点的遍历顺序
  let index = 0
  // 在遍历过程中记录节点的差异
  let patches = {}
  // 深度优先遍历两棵树
  deepTraversal(oldTree, newTree, index, patches)
  // 得到的差异对象返回出去
  return patches
}

function deepTraversal(oldNode, newNode, index, patches) {
  let currentPatch = []
  if (newNode === null) { // 如果新节点没有的话直接不用比较了
    return
  }
  if (typeof oldNode === 'string' && typeof newNode === 'string') {
    // 比较文本节点
    if (oldNode !== newNode) {
      currentPatch.push({
        type: TEXT,
        content: newNode
      })
    }
  } else if (oldNode.tagName === newNode.tagName && oldNode.key === newNode.key) {
    // 节点类型相同
    // 比较节点的属性是否相同
    let propasPatches = diffProps(oldNode, newNode)
    if (propasPatches) {
      currentPatch.push({
        type: PROPS,
        props: propsPatches
      })
    }
    // 递归比较子节点是否相同
    diffChildren(oldNode.children, newNode.children, index, patches, currentPatch)
  } else {
    // 节点不一样,直接替换
    currentPatch.push({ type: REPLACE, node: newNode })
  }

  if (currentPatch.length) {
    // 那个index节点的差异记录下来
    patches[index] = currentPatch
  }

}

// 子数的diff
function diffChildren (oldChildren, newChildren, index, patches, currentPatch) {
  var diffs = listDiff(oldChildren, newChildren)
  newChildren = diffs.children
  // 如果调整子节点,包括移动、删除等的话
  if (diffs.moves.length) {
    var reorderPatch = {
      type: REORDER,
      moves: diffs.moves
    }
    currentPatch.push(reorderPatch)
  }

  var leftNode = null
  var currentNodeIndex = index
  oldChildren.forEach((child, i) => {
    var newChild = newChildren[i]
    // index相加
    currentNodeIndex = (leftNode && leftNode.count) ? currentNodeIndex + leftNode.count + 1 : currentNodeIndex + 1
    // 深度遍历,从左树开始
    deepTraversal(child, newChild, currentNodeIndex, patches)
    // 从左树开始
    leftNode = child
  })
}

// 记录属性的差异
function diffProps (oldNode, newNode) {
  let count = 0 // 声明一个有没没有属性变更的标志
  const oldProps = oldNode.props
  const newProps = newNode.props
  const propsPatches = {}

  // 找出不同的属性
  for (let [key, val] of Object.entries(oldProps)) {
    // 新的不等于旧的
    if (newProps[key] !== val) {
      count++
      propsPatches[key] = newProps[key]
    }
  }
  // 找出新增的属性
  for (let [key, val] of Object.entries(newProps)) {
    if (!oldProps.hasOwnProperty(key)) {
      count++
      propsPatches[key] = val
    }
  }
  // 没有新增 也没有不同的属性 直接返回null
  if (count === 0) {
    return null
  }

  return propsPatches
}

得到差异对象之后,剩下就是把差异对象应用到我们的dom节点上面了。

把差异对象应用到渲染的dom树

到了这里其实就简单多了。我们上面得到的差异对象之后,然后选择同样的深度遍历,如果那个节点有差异的话,判断是上面4种中的哪一种,根据差异对象直接修改这个节点就可以了。

function patch (node, patches) {
  // 也是从0开始
  const step = {
    index: 0
  }
  // 深度遍历
  deepTraversal(node, step, patches)
}

// 深度优先遍历dom结构
function deepTraversal(node, step, patches) {
  // 拿到当前差异对象
  const currentPatches = patches[step.index]
  const len = node.childNodes ? node.childNodes.length : 0
  for (let i = 0; i < len; i++) {
    const child = node.childNodes[i]
    step.index++
    deepTraversal(child, step, patches)
  }
  //如果当前节点存在差异
  if (currentPatches) {
    // 把差异对象应用到当前节点上
    applyPatches(node, currentPatches)
  }
}

这样子,调用patch(rootnode, patches)就直接有针对性的改变有差异的节点了。

path.js完整代码如下:

import {REPLACE, REORDER, PROPS, TEXT} from './diff'
import { setAttr } from './utils'

export function patch (node, patches) {
  // 也是从0开始
  const step = {
    index: 0
  }
  // 深度遍历
  deepTraversal(node, step, patches)
}

// 深度优先遍历dom结构
function deepTraversal(node, step, patches) {
  // 拿到当前差异对象
  const currentPatches = patches[step.index]
  const len = node.childNodes ? node.childNodes.length : 0
  for (let i = 0; i < len; i++) {
    const child = node.childNodes[i]
    step.index++
    deepTraversal(child, step, patches)
  }
  //如果当前节点存在差异
  if (currentPatches) {
    // 把差异对象应用到当前节点上
    applyPatches(node, currentPatches)
  }
}

// 把差异对象应用到当前节点上
function applyPatches(node, currentPatches) {
  currentPatches.forEach(currentPatch => {
    switch (currentPatch.type) {
      // 0: 替换原有节点
      case REPLACE:
        var newNode = (typeof currentPatch.node === 'string') ?  document.createTextNode(currentPatch.node) : currentPatch.node.render()
        node.parentNode.replaceChild(newNode, node)
        break
      // 1: 调整子节点,包括移动、删除等
      case REORDER: 
        moveChildren(node, currentPatch.moves)
        break
      // 2: 修改节点属性
      case PROPS:
        for (let [key, val] of Object.entries(currentPatch.props)) {
          if (val === undefined) {
            node.removeAttribute(key)
          } else {
            setAttr(node, key, val)
          }
        }
        break;
      // 3:修改节点文本内容
      case TEXT:
        if (node.textContent) {
          node.textContent = currentPatch.content
        } else {
          node.nodeValue = currentPatch.content
        }
        break;
      default: 
        throw new Error('Unknow patch type ' + currentPatch.type);
    }
  })
}

// 调整子节点,包括移动、删除等
function moveChildren (node, moves) {
  let staticNodelist = Array.from(node.childNodes)
  const maps = {}
  staticNodelist.forEach(node => {
    if (node.nodeType === 1) {
      const key = node.getAttribute('key')
      if (key) {
        maps[key] = node
      }
    }
  })
  moves.forEach(move => {
    const index = move.index
    if (move.type === 0) { // 变动类型为删除的节点
      if (staticNodeList[index] === node.childNodes[index]) {
        node.removeChild(node.childNodes[index]);
      }
      staticNodeList.splice(index, 1);
    } else {
      let insertNode = maps[move.item.key] 
          ? maps[move.item.key] : (typeof move.item === 'object') 
          ? move.item.render() : document.createTextNode(move.item)
      staticNodelist.splice(index, 0, insertNode);
      node.insertBefore(insertNode, node.childNodes[index] || null)
    }
  })
}

到这里,最基本的虚拟DOM原理已经讲完了,也简单了实现了一个虚拟DOM,如果本文有什么不对的地方请指正。

查看原文

赞 77 收藏 63 评论 3

naice 评论了文章 · 2018-09-18

【翻译】基于 Create React App路由4.0的异步组件加载(Code Splitting)

基于 Create React App路由4.0的异步组件加载

本文章是一个额外的篇章,它可以在你的React app中,帮助加快初始的加载组件时间。当然这个操作不是完全必要的,但如果你好奇的话,请随意跟随这篇文章一起用Create React Appreact路由4.0的异步加载方式来帮助react.js构建大型应用。

代码分割(Code Splitting)

当我们用react.js写我们的单页应用程序时候,这个应用会变得越来越大,一个应用(或者路由页面)可能会引入大量的组件,可是有些组件是第一次加载的时候是不必要的,这些不必要的组件会浪费很多的加载时间。

你可能会注意到 Create React App 在打包完毕之后会生成一个很大的.js文件,这包含了我们应用程序需要的所有JavaScript。但是,如果用户只是加载登录页面去登录网站,我们加载应用程序的其余部分是没有意义的。在我们的应用程序还很小的时候,这并不是一个问题,但是它却是我们程序猿优化的一个东西。为了解决这个问题,Create React App有一个非常简单的代码分割的的方案。

代码分割和 react-router

在我们 react app 中,常见的路由配置可能是像下面一样的

/* Import the components */
import Home from './containers/Home';
import Posts from './containers/Posts';
import NotFound from './containers/NotFound';


/* Use components to define routes */
export default () => (
  <Switch>
    <Route path="/" exact component={Home} />
    <Route path="/posts/:id" exact component={Posts} />
    <Route component={NotFound} />
  </Switch>
);

我们一开始引入这些组件,然后定义好的路径,会根据我们的路由去匹配这些组件。

但是,我们静态地在顶部导入路由中的所有组件。这意味着,不管哪个路由匹配,所有这些组件都被加载。我们只想加载对匹配路由的时候才加载响应的组件。下面我们一步步来完成这个使命。

创建一个异步组件

创建一个js 文件,如:src/components/AsyncComponent.js,代码如下

import React, { Component } from "react";

export default function asyncComponent(importComponent) {
  class AsyncComponent extends Component {
    constructor(props) {
      super(props);

      this.state = {
        component: null
      };
    }

    async componentDidMount() {
      const { default: component } = await importComponent();

      this.setState({
        component: component
      });
    }

    render() {
      const C = this.state.component;

      return C ? <C {...this.props} /> : null;
    }
  }

  return AsyncComponent;
}

我们在这里做了一些事情:

  1. 这个asyncComponent 函数接受一个importComponent 的参数,importComponent 调用时候将动态引入给定的组件。

  2. componentDidMount 我们只是简单地调用importComponent 函数,并将动态加载的组件保存在状态中。

  3. 最后,如果完成渲染,我们有条件地提供组件。在这里我们如果不写null的话,也可提供一个菊花图,代表着组件正在渲染。

使用异步组件

现在让我们使用我们的异步组件,而不是像开始的静态去引入。

import Home from './containers/Home';

我们要用asyncComponent组件来动态引入我们需要的组件。

tip: 别忘记 先 import asyncComponent from './AsyncComponent

const AsyncHome = asyncComponent(() => import('./containers/Home'));

我们将要使用 AsyncHome 这个组件在我们的路由里面

<Route path="/" exact component={AsyncHome} />

现在让我们回到Notes项目并应用这些更改。

src/Routes.js

import React from 'react';
import { Route, Switch } from 'react-router-dom';
import asyncComponent from './components/AsyncComponent';
import AppliedRoute from './components/AppliedRoute';
import AuthenticatedRoute from './components/AuthenticatedRoute';
import UnauthenticatedRoute from './components/UnauthenticatedRoute';

const AsyncHome     = asyncComponent(() => import('./containers/Home'));
const AsyncLogin    = asyncComponent(() => import('./containers/Login'));
const AsyncNotes    = asyncComponent(() => import('./containers/Notes'));
const AsyncSignup   = asyncComponent(() => import('./containers/Signup'));
const AsyncNewNote  = asyncComponent(() => import('./containers/NewNote'));
const AsyncNotFound = asyncComponent(() => import('./containers/NotFound'));

export default ({ childProps }) => (
  <Switch>
    <AppliedRoute path="/" exact component={AsyncHome} props={childProps} />
    <UnauthenticatedRoute path="/login" exact component={AsyncLogin} props={childProps} />
    <UnauthenticatedRoute path="/signup" exact component={AsyncSignup} props={childProps} />
    <AuthenticatedRoute path="/notes/new" exact component={AsyncNewNote} props={childProps} />
    <AuthenticatedRoute path="/notes/:id" exact component={AsyncNotes} props={childProps} />
    { /* Finally, catch all unmatched routes */ }
    <Route component={AsyncNotFound} />
  </Switch>
);

只需几次更改就相当酷了。我们的app都是设置了代码分割而的。也没有增加太多的复杂性。
这里我们看看之前的这个src/Routes.js路由文件

import React from 'react';
import { Route, Switch } from 'react-router-dom';
import AppliedRoute from './components/AppliedRoute';
import AuthenticatedRoute from './components/AuthenticatedRoute';
import UnauthenticatedRoute from './components/UnauthenticatedRoute';

import Home from './containers/Home';
import Login from './containers/Login';
import Notes from './containers/Notes';
import Signup from './containers/Signup';
import NewNote from './containers/NewNote';
import NotFound from './containers/NotFound';

export default ({ childProps }) => (
  <Switch>
    <AppliedRoute path="/" exact component={Home} props={childProps} />
    <UnauthenticatedRoute path="/login" exact component={Login} props={childProps} />
    <UnauthenticatedRoute path="/signup" exact component={Signup} props={childProps} />
    <AuthenticatedRoute path="/notes/new" exact component={NewNote} props={childProps} />
    <AuthenticatedRoute path="/notes/:id" exact component={Notes} props={childProps} />
    { /* Finally, catch all unmatched routes */ }
    <Route component={NotFound} />
  </Switch>
);

注意,不要在顶部的引入所有的组件。我们正在创建这些代码分割的功能,以便在必要时为我们进行动态导入。

现在你运行npm run build 您将看到代码已经被分割成一个个小文件。

图片描述

下面是部署好的在网站的真实截图

图片描述

每个.chunk.js都是需要的时候才加载的。当然我们的程序是相当小的,并且分离在各个部分的小组件,是不需要这样子按需加载的。还是看你项目的需求。

原文地址:http://serverless-stack.com/c...

查看原文