前言

渲染器的核心就是 Diff 算法。简单来说,当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

减少 DOM 操作的性能开销

核心 Diff 只关心新旧虚拟节点都存在一组子节点的情况。针对两组子节点的更新,我们之前采用了一种简单直接的手段,即卸载全部旧子节点,再挂载全部新子节点。这么做的确可以完成更新,但由于没有复用任何 DOM 元素,所以会产生极大的性能开销。

// 旧 vnode
const oldNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1' },
    { type: 'p', children: '2' },
    { type: 'p', children: '3' }
  ]
}

// 新 vnode
const newNode = {
  type: 'div',
  children: [
    { type: 'p', children: '4' },
    { type: 'p', children: '5' },
    { type: 'p', children: '6' }
  ]
}

按照之前的做法,当更新子节点时,我们需要执行 6 次 DOM 操作:

  • 卸载所有旧子节点,需要 3 次 DOM 删除操作;
  • 挂载所有新子节点,需要 3 次 DOM 添加操作。

但是,通过观察上面新旧 vnode 的子节点,可以发现:更新前后的所有子节点都是 p 标签,即标签元素不变;只有 p 标签的子节点(文本节点)会发生变化。

例如,oldVNode 的第一个子节点是一个 p 标签,且该 p 标签的子节点类型是文本节点,内容是 ‘1’。而 newVNode 的第一个子节点也是一个 p 标签,它的子节点的类型也是文本节点,内容是 ‘4’。可以发现,更新前后改变的只有 p 标签文本节点的内容。

所以,最理想的更新方式是,直接更新这个 p 标签的文本节点的内容。这样只需要一次 DOM 操作,即可完成一个 p 标签更新。新旧虚拟节点都有 3 个 p标签作为子节点,所以一共只需要 3 次 DOM 操作就可以完成全部节点的更新。相比原来需要执行 6 次 DOM 操作才能完成更新的方式,其性能提升了一倍。

按照这个思路,我们可以重新实现两组子节点的更新逻辑,如下面 patchChildren 函数的代码所示:

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    // 省略部分代码
  } else if (Array.isArray(n2.children)) {
    // 重新实现两组子节点的更新方式
    // 新旧 children
    const oldChildren = n1.children
    const newChildren = n2.children
    // 遍历旧的 children
    for (let i = 0; i < oldChildren.length; i++) {
      // 调用 patch 函数逐个更新子节点
      patch(oldChildren[i], newChildren[i])
    }
  } else {
    // 省略部分代码
  }
}

在这段代码中,oldChildren 和 newChildren 分别是旧的一组子节点和新的一组子节点。我们遍历前者,并将两者中对应位置的节点分别传递给 patch 函数进行更新。patch 函数在执行更新时,发现新旧子节点只有文本内容不同,因此只会更新其文本节点的内容。这样,我们就成功地将 6 次 DOM 操作减少为 3 次。其中菱形代表新子节点,矩形代表旧子节点,圆形代表真实 DOM 节点。

render-diff

这种做法虽然能够减少 DOM 操作次数,但问题也很明显。在上面的代码中,我们通过遍历旧的一组子节点,并假设新的一组子节点的数量与之相同,只有在这种情况下,这段代码才能正确地工作。但是,新旧两组子节点的数量未必相同。当新的一组子节点的数量少于旧的一组子节点的数量时,意味着有些节点在更新后应该被卸载。

render-diff

前言

Hot Module Replacement(以下简称 HMR)是 Webpack 发展至今一大特性 ,当你对代码进行修改并保存后,Webpack 将对代码重新打包,并将新的模块发送到浏览器端,浏览器通过新的模块替换老的模块,这样在不刷新浏览器的前提下就能够对应用进行更新。

例如,在开发 Web 页面过程中,当你点击按钮,出现一个弹窗的时候,发现弹窗标题没有对齐,这时候你修改 CSS 样式,然后保存,在浏览器没有刷新的前提下,标题样式发生了改变。感觉就像在 Chrome 的开发者工具中直接修改元素样式一样。

模块热替换(hot module replacement)

模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。主要是通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面期间丢失的应用程序状态。

  • 只更新变更内容,以节省宝贵的开发时间。

  • 在源代码中 CSS/JS 产生修改时,会立刻在浏览器中进行更新,这几乎相当于在浏览器 devtools 直接更改样式。

为什么需要 HMR

在 webpack HMR 功能之前,已经有很多 live reload 的工具或库,比如 live-server,这些库监控文件的变化,然后通知浏览器端刷新页面,那么我们为什么还需要 HMR 呢?答案其实在上文中已经提及一些。

  • live reload 工具并不能够保存应用的状态(states),当刷新页面后,应用之前状态丢失,还是上文中的例子,点击按钮出现弹窗,当浏览器刷新后,弹窗也随即消失,要恢复到之前状态,还需再次点击按钮。而 webapck HMR 则不会刷新浏览器,而是运行时对模块进行热替换,保证了应用状态不会丢失,提升了开发效率。

  • 在古老的开发流程中,我们可能需要手动运行命令对代码进行打包,并且打包后再手动刷新浏览器页面,而这一系列重复的工作都可以通过 HMR 工作流来自动化完成,让更多的精力投入到业务中,而不是把时间浪费在重复的工作上。

  • HMR 兼容市面上大多前端框架或库,比如 React Hot LoaderVue-loader,能够监听 React 或者 Vue 组件的变化,实时将最新的组件更新到浏览器端。Elm Hot Loader 支持通过 webpack 对 Elm 语言代码进行转译并打包,当然它也实现了 HMR 功能。

HMR 的工作原理图解

初识 HMR 的时候觉得其很神奇,一直有一些疑问萦绕在脑海。

  • webpack 可以将不同的模块打包成 bundle 文件或者几个 chunk 文件,但是当我通过 webpack HMR 进行开发的过程中,我并没有在我的 dist 目录中找到 webpack 打包好的文件,它们去哪呢?

  • 通过查看 webpack-dev-server 的 package.json 文件,我们知道其依赖于 webpack-dev-middleware 库,那么 webpack-dev-middleware 在 HMR 过程中扮演什么角色?

  • 使用 HMR 的过程中,通过 Chrome 开发者工具我知道浏览器是通过 websocket 和 webpack-dev-server 进行通信的,但是 websocket 的 message 中并没有发现新模块代码。打包后的新模块又是通过什么方式发送到浏览器端的呢?为什么新的模块不通过 websocket 随消息一起发送到浏览器端呢?

  • 浏览器拿到最新的模块代码,HMR 又是怎么将老的模块替换成新的模块,在替换的过程中怎样处理模块之间的依赖关系?

  • 当模块的热替换过程中,如果替换模块失败,有什么回退机制吗?

带着上面的问题,于是决定深入到 webpack 源码,寻找 HMR 底层的奥秘。

webpack-optimization

图一:HMR 工作流程图解

上图是webpack 配合 webpack-dev-server 进行应用开发的模块热更新流程图。

上图底部红色框内是服务端,而上面的橙色框是浏览器端。

绿色的方框是 webpack 代码控制的区域。蓝色方框是 webpack-dev-server 代码控制的区域,洋红色的方框是文件系统,文件修改后的变化就发生在这,而青色的方框是应用本身。

上图显示了我们修改代码到模块热更新完成的一个周期,通过深绿色的阿拉伯数字符号已经将 HMR 的整个过程标识了出来。

  • 第一步,在 webpack 的 watch 模式下,文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码通过简单的 JavaScript 对象保存在内存中。

  • 第二步是 webpack-dev-server 和 webpack 之间的接口交互,而在这一步,主要是 dev-server 的中间件 webpack-dev-middleware 和 webpack 之间的交互,webpack-dev-middleware 调用 webpack 暴露的 API对代码变化进行监控,并且告诉 webpack,将代码打包到内存中。

  • 第三步是 webpack-dev-server 对文件变化的一个监控,这一步不同于第一步,并不是监控代码变化重新打包。当我们在配置文件中配置了devServer.watchContentBase 为 true 的时候,Server 会监听这些配置文件夹中静态文件的变化,变化后会通知浏览器端对应用进行 live reload。注意,这儿是浏览器刷新,和 HMR 是两个概念。

  • 第四步也是 webpack-dev-server 代码的工作,该步骤主要是通过 sockjs(webpack-dev-server 的依赖)在浏览器端和服务端之间建立一个 websocket 长连接,将 webpack 编译打包的各个阶段的状态信息告知浏览器端,同时也包括第三步中 Server 监听静态文件变化的信息。浏览器端根据这些 socket 消息进行不同的操作。当然服务端传递的最主要信息还是新模块的 hash 值,后面的步骤根据这一 hash 值来进行模块热替换。

webpack-dev-server/client 端并不能够请求更新的代码,也不会执行热更模块操作,而把这些工作又交回给了 webpackwebpack/hot/dev-server 的工作就是根据
webpack-dev-server/client 传给它的信息以及 dev-server 的配置决定是刷新浏览器呢还是进行模块热更新。当然如果仅仅是刷新浏览器,也就没有后面那些步骤了。

HotModuleReplacement.runtime 是客户端 HMR 的中枢,它接收到上一步传递给他的新模块的 hash 值,它通过 JsonpMainTemplate.runtime 向 server 端发送 Ajax 请求,服务端返回一个 json,该 json 包含了所有要更新的模块的 hash 值,获取到更新列表后,该模块再次通过 jsonp 请求,获取到最新的模块代码。这就是上图中 7、8、9 步骤。

而第 10 步是决定 HMR 成功与否的关键步骤,在该步骤中,HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,在决定更新模块后,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。

最后一步,当 HMR 失败后,回退到 live reload 操作,也就是进行浏览器刷新来获取最新打包代码。

运用 HMR 的简单例子

在上一个部分,通过一张 HMR 流程图,简要的说明了 HMR 进行模块热更新的过程。当然你可能感觉还是很迷糊,对上面出现的一些英文名词也可能比较陌生(上面这些英文名词代表着代码仓库或者仓库中的文件模块),没关系,在这一部分,我将通过一个最简单最纯粹的例子,通过分析 wepack及 webpack-dev-server 源码详细说明各个库在 HMR 过程中的具体职责。

这边我通过一个简单的vue例子示例一下,这边贴一个仓库的连接github.com/ikkkp/webpack-vue-demo

在开始这个例子之前简单对这个仓库文件进行下说明,仓库中包含文件如下:

webpack-optimization

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {
    VueLoaderPlugin
} = require('vue-loader');
const webpack = require('webpack'); // 引入 webpack
const AutoImport = require('unplugin-auto-import/webpack')
const Components = require('unplugin-vue-components/webpack')
const {
    ElementPlusResolver
} = require('unplugin-vue-components/resolvers')

/**
* @description 
* @version 1.0
* @author Huangzl
* @fileName webpack.base.config.js
* @date 2023/11/10 11:00:59
*/

module.exports = {
    entry: {
        main: './src/main',
        //单页应用开发模式禁用多入口
    },
    resolveLoader: {
        modules: [
            'node_modules',
            path.resolve(__dirname, './src/loader')
        ]
    },
    output: {
        filename: '[id].[fullhash].js', // 使用 [fullhash] 替代 [hash],这是新版本 webpack 的写法
        path: path.join(__dirname, 'dist'),
        publicPath: './'
    },
    module: {
        rules: [{
            test: /\.vue$/,
            loader: 'vue-loader'
        },
        {
            test: /\.css$/,
            use: [
                'style-loader',
                {
                    loader: 'css-loader',
                    options: {
                        importLoaders: 1
                    }
                },
                'postcss-loader'
            ]
        }, {
            test: /\.js$/,
            use: ['babel-loader', {
                loader: 'company-loader',
                options: {
                    sign: 'we-doctor@2021',
                },
            },],
            exclude: /node_modules/,
        },
        {
            test: /\.(ico|png|jpg|gif|svg|eot|woff|woff2|ttf)$/,
            loader: 'file-loader',
            options: {
                name: '[name].[ext]?[hash]'
            }
        },

        ]
    },

    plugins: [
        new HtmlWebpackPlugin({
            template: './public/index.html'
        }),
        new VueLoaderPlugin(),
        new webpack.DefinePlugin({
            BASE_URL: JSON.stringify('./') // 这里定义了 BASE_URL 为根路径 '/'
        }),
        AutoImport({
            resolvers: [ElementPlusResolver()],
        }),
        Components({
            resolvers: [ElementPlusResolver()],
        }),
    ],
    optimization: {
        splitChunks: {
            chunks: 'all', // 只处理异步模块
            maxSize: 20000000, // 设置最大的chunk大小为2MB
        },
    },
};

值得一提的是,在上面的配置中并没有配置 HotModuleReplacementPlugin,原因在于当我们设置 devServer.hot 为 true 后,并且在package.json 文件中添加如下的 script 脚本:

“start”: “webpack-dev-server –hot –open”

添加 —hot 配置项后,devServer 会告诉 webpack 自动引入 HotModuleReplacementPlugin 插件,而不用我们再手动引入了。

上面给的是webpack.base.config.js的内容,我们下面通过修改App.vue的内容进行:

- <div>hello</div> // 将 hello 字符串修改为 hello world
+ <div>hello world</div>

第一步:webpack 对文件系统进行 watch 打包到内存中

webpack-dev-middleware 调用 webpack 的 api 对文件系统 watch,当 hello.js 文件发生改变后,webpack 重新对文件进行编译打包,然后保存到内存中。

// webpack-dev-middleware/lib/Shared.js
if(!options.lazy) {
    var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
    context.watching = watching;
}

你可能会疑问了,为什么 webpack 没有将文件直接打包到 output.path 目录下呢?文件又去了哪儿?原来 webpack 将 bundle.js 文件打包到了内存中,不生成文件的原因就在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs,memory-fs 是 webpack-dev-middleware 的一个依赖库,webpack-dev-middleware 将 webpack 原本的 outputFileSystem 替换成了MemoryFileSystem 实例,这样代码就将输出到内存中。webpack-dev-middleware 中该部分源码如下:

// webpack-dev-middleware/lib/Shared.js
var isMemoryFs = !compiler.compilers && compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
    fs = compiler.outputFileSystem;
} else {
    fs = compiler.outputFileSystem = new MemoryFileSystem();
}

首先判断当前 fileSystem 是否已经是 MemoryFileSystem 的实例,如果不是,用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 javascript 对象保存在了内存中,当浏览器请求 bundle.js 文件时,devServer就直接去内存中找到上面保存的 javascript 对象返回给浏览器端。

第二步:devServer 通知浏览器端文件发生改变

在这一阶段,sockjs 是服务端和浏览器端之间的桥梁,在启动 devServer 的时候,sockjs 在服务端和浏览器端建立了一个 webSocket 长连接,以便将 webpack 编译和打包的各个阶段状态告知浏览器,最关键的步骤还是 webpack-dev-server 调用 webpack api 监听 compile的 done 事件,当compile 完成后,webpack-dev-server通过 _sendStatus 方法将编译打包后的新模块 hash 值发送到浏览器端。

// webpack-dev-server/lib/Server.js
compiler.plugin('done', (stats) => {
  // stats.hash 是最新打包文件的 hash 值
  this._sendStats(this.sockets, stats.toJson(clientStats));
  this._stats = stats;
});
...
Server.prototype._sendStats = function (sockets, stats, force) {
  if (!force && stats &&
  (!stats.errors || stats.errors.length === 0) && stats.assets &&
  stats.assets.every(asset => !asset.emitted)
  ) { return this.sockWrite(sockets, 'still-ok'); }
  // 调用 sockWrite 方法将 hash 值通过 websocket 发送到浏览器端
  this.sockWrite(sockets, 'hash', stats.hash);
  if (stats.errors.length > 0) { this.sockWrite(sockets, 'errors', stats.errors); } 
  else if (stats.warnings.length > 0) { this.sockWrite(sockets, 'warnings', stats.warnings); }      else { this.sockWrite(sockets, 'ok'); }
};

第三步:webpack-dev-server/client 接收到服务端消息做出响应

可能你又会有疑问,我并没有在业务代码里面添加接收 websocket 消息的代码,也没有在 webpack.config.js 中的 entry 属性中添加新的入口文件,那么 bundle.js 中接收 websocket 消息的代码从哪来的呢?原来是 webpack-dev-server 修改了webpack 配置中的 entry 属性,在里面添加了 webpack-dev-client 的代码,这样在最后的 bundle.js 文件中就会有接收 websocket 消息的代码了。

webpack-dev-server/client 当接收到 type 为 hash 消息后会将 hash 值暂存起来,当接收到 type 为 ok 的消息后对应用执行 reload 操作,如下图所示,hash 消息是在 ok 消息之前。

webpack-optimization

在 reload 操作中,webpack-dev-server/client 会根据 hot 配置决定是刷新浏览器还是对代码进行热更新(HMR)。

首先将 hash 值暂存到 currentHash 变量,当接收到 ok 消息后,对 App 进行 reload。如果配置了模块热更新,就调用 webpack/hot/emitter 将最新 hash 值发送给 webpack,然后将控制权交给 webpack 客户端代码。如果没有配置模块热更新,就直接调用 location.reload 方法刷新页面。

第四步:webpack 接收到最新 hash 值验证并请求模块代码

在这一步,其实是 webpack 中三个模块(三个文件,后面英文名对应文件路径)之间配合的结果,首先是 webpack/hot/dev-server(以下简称 dev-server) 监听第三步 webpack-dev-server/client 发送的 webpackHotUpdate 消息,调用 webpack/lib/HotModuleReplacement.runtime(简称 HMR runtime)中的 check 方法,检测是否有新的更新,在 check 过程中会利用 webpack/lib/JsonpMainTemplate.runtime(简称 jsonp runtime)中的两个方法 hotDownloadUpdateChunkhotDownloadManifest , 第二个方法是调用 AJAX 向服务端请求是否有更新的文件,如果有将发更新的文件列表返回浏览器端,而第一个方法是通过 jsonp 请求最新的模块代码,然后将代码返回给 HMR runtimeHMR runtime 会根据返回的新模块代码做进一步处理,可能是刷新页面,也可能是对模块进行热更新。

webpack-optimization

值得注意的是,两次请求的都是使用上一次的 hash 值拼接的请求文件名,hotDownloadManifest 方法返回的是最新的 hash 值,hotDownloadUpdateChunk 方法返回的就是最新 hash 值对应的代码块。然后将新的代码块返回给 HMR runtime,进行模块热更新

第五步:HotModuleReplacement.runtime 对模块进行热更新

这一步是整个模块热更新(HMR)的关键步骤,而且模块热更新都是发生在HMR runtime 中的 hotApply 方法中

// webpack/lib/HotModuleReplacement.runtime
function hotApply() {
    // ...
    var idx;
    var queue = outdatedModules.slice();
    while(queue.length > 0) {
        moduleId = queue.pop();
        module = installedModules[moduleId];
        // ...
        // remove module from cache
        delete installedModules[moduleId];
        // when disposing there is no need to call dispose handler
        delete outdatedDependencies[moduleId];
        // remove "parents" references from all children
        for(j = 0; j < module.children.length; j++) {
            var child = installedModules[module.children[j]];
            if(!child) continue;
            idx = child.parents.indexOf(moduleId);
            if(idx >= 0) {
                child.parents.splice(idx, 1);
            }
        }
    }
    // ...
    // insert new code
    for(moduleId in appliedUpdate) {
        if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
    // ...
}

从上面 hotApply 方法可以看出,模块热替换主要分三个阶段,第一个阶段是找出 outdatedModulesoutdatedDependencies,这儿我没有贴这部分代码,有兴趣可以自己阅读源码。第二个阶段从缓存中删除过期的模块和依赖,如下:

delete installedModules[moduleId];
delete outdatedDependencies[moduleId];

第三个阶段是将新的模块添加到 modules 中,当下次调用 webpack_require (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。

模块热更新的错误处理,如果在热更新过程中出现错误,热更新将回退到刷新浏览器,这部分代码在 dev-server 代码中,简要代码如下:

module.hot.check(true).then(function(updatedModules) {
    if(!updatedModules) {
        return window.location.reload();
    }
    // ...
}).catch(function(err) {
    var status = module.hot.status();
    if(["abort", "fail"].indexOf(status) >= 0) {
        window.location.reload();
    }
});

dev-server 先验证是否有更新,没有代码更新的话,重载浏览器。如果在 hotApply 的过程中出现 abort 或者 fail 错误,也进行重载浏览器。

webpack-optimization

Hadoop Yarn 是什么

在古老的 Hadoop1.0 中,MapReduce 的 JobTracker 负责了太多的工作,包括资源调度,管理众多的 TaskTracker 等工作。这自然是不合理的,于是 Hadoop 在 1.0 到 2.0 的升级过程中,便将 JobTracker 的资源调度工作独立了出来,而这一改动,直接让 Hadoop 成为大数据中最稳固的那一块基石。,而这个独立出来的资源管理框架,就是 Yarn 。

在详细介绍 Yarn 之前,我们先简单聊聊 Yarn ,Yarn 的全称是 Yet Another Resource Negotiator ,意思是“另一种资源调度器”,这种命名和“有间客栈”这种可谓是异曲同工之妙。这里多说一句,以前 Java 有一个项目编译工具,叫做 Ant,他的命名也是类似的,叫做 “Another Neat Tool”的缩写,翻译过来是“另一种整理工具”。

既然都叫做资源调度器了,那么自然,它的功能也是负责资源管理和调度的,接下来,我们就深入到 Yarn 这个东西内部一探究竟吧。

Yarn 架构

hadoop-Yarn

① Client:客户端,负责向集群提交作业。

② ResourceManager:集群主进程,仲裁中心,负责集群资源管理和任务调度。

③ Scheduler:资源仲裁模块。

④ ApplicationManager:选定,启动和监管ApplicationMaster。

⑤ NodeManager:集群从进程,管理监视Containers,执行具体任务。

⑥ Container:本机资源集合体,如某Container为4个CPU,8GB内存。

⑦ ApplicationMaster:任务执行和监管中心。

三个主要组件

再看最上面的图,我们能直观发现的两个主要的组件是 ResourceManagerNodeManager ,但其实还有一个 ApplicationMaster 在图中没有直观显示。我们分别来看这三个组件。

ResourceManager

我们先来说说上图中最中央的那个 ResourceManager(RM)。从名字上我们就能知道这个组件是负责资源管理的,整个系统有且只有一个 RM ,来负责资源的调度。

它也包含了两个主要的组件:定时调用器(Scheduler)以及应用管理器(ApplicationManager)

定时调度器(Scheduler):从本质上来说,定时调度器就是一种策略,或者说一种算法。当 Client 提交一个任务的时候,它会根据所需要的资源以及当前集群的资源状况进行分配。注意,它只负责向应用程序分配资源,并不做监控以及应用程序的状态跟踪。

应用管理器(ApplicationManager):同样,听名字就能大概知道它是干嘛的。应用管理器就是负责管理 Client 用户提交的应用。上面不是说到定时调度器(Scheduler)不对用户提交的程序监控嘛,其实啊,监控应用的工作正是由应用管理器(ApplicationManager)完成的。

ApplicationMaster

每当 Client 提交一个 Application 时候,就会新建一个 ApplicationMaster 。由这个 ApplicationMaster 去与 ResourceManager 申请容器资源,获得资源后会将要运行的程序发送到容器上启动,然后进行分布式计算。

这里可能有些难以理解,为什么是把运行程序发送到容器上去运行?如果以传统的思路来看,是程序运行着不动,然后数据进进出出不停流转。但当数据量大的时候就没法这么玩了,因为海量数据移动成本太大,时间太长。但是中国有一句老话山不过来,我就过去。大数据分布式计算就是这种思想,既然大数据难以移动,那我就把容易移动的应用程序发布到各个节点进行计算呗,这就是大数据分布式计算的思路。

NodeManager

NodeManager 是 ResourceManager 在每台机器的上代理,负责容器的管理,并监控他们的资源使用情况(cpu,内存,磁盘及网络等),以及向 ResourceManager/Scheduler 提供这些资源使用报告。

Yarn的主要思想是将MRv1版JobTracker的两大功能——资源管理和任务调度,拆分成两个独立的进程:

hadoop-Yarn

  • Yarn依旧是master/slave结构

  • 主进程ResourceManager是整个集群资源仲裁中心

  • 从进程NodeManager管理本机资源

  • ResourceManager和从属节点的进程NodeManager组成了Hadoop 2.0的分布式数据计算框架

提交一个 Application 到 Yarn 的流程

hadoop-Yarn

这张图简单地标明了提交一个程序所经历的流程,接下来我们来具体说说每一步的过程。

  • Client 向 Yarn 提交 Application,这里我们假设是一个 MapReduce 作业。

  • ResourceManager 向 NodeManager 通信,为该 Application 分配第一个容器。并在这个容器中运行这个应用程序对应的 ApplicationMaster。

  • ApplicationMaster 启动以后,对 作业(也就是 Application) 进行拆分,拆分 task 出来,这些 task 可以运行在一个或多个容器中。然后向
    ResourceManager 申请要运行程序的容器,并定时向 ResourceManager 发送心跳。

  • 申请到容器后,ApplicationMaster 会去和容器对应的 NodeManager 通信,而后将作业分发到对应的 NodeManager 中的容器去运行,这里会将拆分后的 MapReduce 进行分发,对应容器中运行的可能是 Map 任务,也可能是 Reduce 任务。

  • 容器中运行的任务会向 ApplicationMaster 发送心跳,汇报自身情况。当程序运行完成后, ApplicationMaster 再向 ResourceManager 注销并释放容器资源。
    以上就是一个作业的大体运行流程。

hadoop-Yarn

Yarn 架构典型拓扑

除了ResourceManagerNodeManager两个实体外,Yarn还包括WebAppProxyServerJobHistoryServer两个实体。

hadoop-Yarn

JobHistoryServer:管理已完成的Yarn任务

  • 历史任务的日志和执行时的各种统计信息统一由JobTracker管理
  • Yarn将管理历史任务的功能抽象成一独立实体JobHistoryServer

WebAppProxyServer:任务执行时的Web页面代理

  • 通过使用代理,不仅进一步降低了ResourceManager的压力,还能降低Yarn受到的Web攻击
  • 负责监管具体MapReduce任务执行全过程,将从Container那里收集过的任务执行信息汇总并显示到一个Web界面上

Yarn 调度策略

容量调度算法
CapacityScheduler是一种多用户多任务调度策略,它以队列为单位划分任务,以Container为单位分配资源

hadoop-Yarn

公平调度策略
FairScheduler是一种允许多个Yarn任务公平使用集群资源的可插拔式调度策略

hadoop-Yarn

HDFS设计原则

设计目标

存储非常大的文件:这里非常大指的是几百M、G、或者TB级别。

  • 采用流式的数据访问方式: HDFS基于这样的一个假设:最有效的数据处理模式是一次写入、多次读取数据集经常从数据源生成或者拷贝一次,然后在其上做很多分析工作
    分析工作经常读取其中的大部分数据,即使不是全部。 因此读取整个数据集所需时间比读取第一条记录的延时更重要。

  • 运行于商业硬件上: Hadoop不需要特别贵的、reliable的(可靠的)机器,可运行于普通商用机器(可以从多家供应商采购) ,商用机器不代表低端机器。在集群中(尤其是大的集群),节点失败率是比较高的HDFS的目标是确保集群在节点失败的时候不会让用户感觉到明显的中断。

HDFS不适合的应用类型

有些场景不适合使用HDFS来存储数据。下面列举几个:

  1. 低延时的数据访问
    对延时要求在毫秒级别的应用,不适合采用HDFS。HDFS是为高吞吐数据传输设计的,因此可能牺牲延时HBase更适合低延时的数据访问。

  2. 大量小文件
    文件的元数据(如目录结构,文件block的节点列表,block-node mapping)保存在NameNode的内存中, 整个文件系统的文件数量会受限于NameNode的内存大小。
    经验而言,一个文件/目录/文件块一般占有150字节的元数据内存空间。如果有100万个文件,每个文件占用1个文件块,则需要大约300M的内存。因此十亿级别的文件数量在现有商用机器上难以支持。

  3. 多方读写,需要任意的文件修改
    HDFS采用追加(append-only)的方式写入数据。不支持文件任意offset的修改。不支持多个写入器(writer)

HDFS定位

为提高扩展性,HDFS采用了master/slave架构来构建分布式存储集群,这种架构很容易向集群中任意添加或删除slave。

HDFS是Hadoop生态系统中的一个重要组件,它是一个分布式文件系统,旨在存储大量数据,并提供高吞吐量的数据访问。HDFS的设计目标是将数据存储在廉价的硬件上,并提供高容错性。它通过将数据分散到集群中的多个节点上来实现这一目标。HDFS的定位是作为一个批处理系统,适用于大规模数据的离线处理。

HDFS的主要特点包括:

  • 高容错性:HDFS将数据分散到多个节点上,因此即使某个节点出现故障,数据仍然可以通过其他节点进行访问。
  • 高吞吐量:HDFS的设计目标是支持大规模数据的批处理,因此它提供了高吞吐量的数据访问。
  • 适用于大文件:HDFS适用于存储大文件,因为它将文件分成多个块进行存储,并将这些块分散到多个节点上。
  • 流式数据访问:HDFS支持流式数据访问,这意味着它可以高效地处理大量的数据流。

hadoop-HDFS

HDFS体系架构

HDFS采用master/slave体系来构建分布式存储服务,提高了HDFS的可扩展性又简化了架构设计。
HDFS里将文件分块存储,优化存储颗粒度。namenode统一管理所有slave机器datanode存储空间,datanode以块为单位存储实际的数据。真正的文件I/O操作时客户端直接和datanode交互。

HDFS核心概念

Blocks

物理磁盘中有块的概念,磁盘的物理Block是磁盘操作最小的单元,读写操作均以Block为最小单元,一般为512 Byte。文件系统在物理Block之上抽象了另一层概念,文件系统Block物理磁盘Block的整数倍。通常为几KB。Hadoop提供的df、fsck这类运维工具都是在文件系统的Block级别上进行操作。

HDFS的Block块比一般单机文件系统大得多,默认为128M。HDFS的文件被拆分成block-sized的chunk,chunk作为独立单元存储。比Block小的文件不会占用整个Block,只会占据实际大小。例如, 如果一个文件大小为1M,则在HDFS中只会占用1M的空间,而不是128M。

HDFS的Block为什么这么大?
是为了最小化查找(seek)时间,控制定位文件与传输文件所用的时间比例。假设定位到Block所需的时间为10ms,磁盘传输速度为100M/s。如果要将定位到Block所用时间占传输时间的比例控制1%,则Block大小需要约100M。
但是如果Block设置过大,在MapReduce任务中,Map或者Reduce任务的个数 如果小于集群机器数量,会使得作业运行效率很低。

Block抽象的好处

  • Block的拆分使得单个文件大小可以大于整个磁盘的容量,构成文件的Block可以分布在整个集群, 理论上,单个文件可以占据集群中所有机器的磁盘。
  • Block的抽象也简化了存储系统,对于Block,无需关注其权限,所有者等内容(这些内容都在文件级别上进行控制)。
  • Block作为容错和高可用机制中的副本单元,即以Block为单位进行复制。

Namenode & Datanode

整个HDFS集群由Namenode和Datanode构成master-worker(主从)模式。Namenode负责构建命名空间,管理文件的元数据等,而Datanode负责实际存储数据,负责读写工作。

Namenode

Namenode存放文件系统树及所有文件、目录的元数据。元数据持久化为2种形式:

  • namespace image
  • edit log

但是持久化数据中不包括Block所在的节点列表,及文件的Block分布在集群中的哪些节点上,这些信息是在系统重启的时候重新构建(通过Datanode汇报的Block信息)。
在HDFS中,Namenode可能成为集群的单点故障,Namenode不可用时,整个文件系统是不可用的。HDFS针对单点故障提供了2种解决机制:

  1. 备份持久化元数据
    将文件系统的元数据同时写到多个文件系统, 例如同时将元数据写到本地文件系统及NFS。这些备份操作都是同步的、原子的。

  2. Secondary Namenode
    Secondary节点定期合并主Namenode的namespace image和edit log, 避免edit log过大,通过创建检查点checkpoint来合并。它会维护一个合并后的namespace image副本, 可用于在Namenode完全崩溃时恢复数据。下图为Secondary Namenode的管理界面:

hadoop-HDFS

Secondary Namenode通常运行在另一台机器,因为合并操作需要耗费大量的CPU和内存。其数据落后于Namenode,因此当Namenode完全崩溃时,会出现数据丢失。 通常做法是拷贝NFS中的备份元数据到Second,将其作为新的主Namenode。
在HA(High Availability高可用性)中可以运行一个Hot Standby,作为热备份,在Active Namenode故障之后,替代原有Namenode成为Active Namenode。

Datanode

数据节点负责存储和提取Block,读写请求可能来自namenode,也可能直接来自客户端。数据节点周期性向Namenode汇报自己节点上所存储的Block相关信息。

hadoop-HDFS

经典HDFS体系架构

NameNode负责管理文件系统的元数据信息,而DataNode则负责存储文件块的实际数据。 这种分工使得HDFS能够高效地存储和管理大规模数据。

hadoop-HDFS

具体来说,当一个客户端需要读取或写入一个文件时,它会向NameNode发送请求。NameNode会返回文件的元数据信息和文件块的位置信息。客户端根据这些信息与DataNode进行通信,从而读取或写入文件块的实际数据。

因此,NameNode和DataNode在HDFS体系架构中扮演着不同的角色。

作用上的区别是什么?

HDFS是Hadoop分布式文件系统的缩写,是Hadoop生态系统中的一个重要组件。HDFS的体系架构包括一个NameNode和多个DataNode。NameNode是HDFS的主节点,负责管理文件系统的命名空间、文件的元数据信息以及文件块的位置信息。而DataNode则是HDFS的从节点,负责存储文件块的实际数据。

具体来说,当一个客户端需要读取或写入一个文件时,它会向NameNode发送请求。NameNode会返回文件的元数据信息和文件块的位置信息。客户端根据这些信息与DataNode进行通信,从而读取或写入文件块的实际数据。

hadoop-HDFS

一般拓扑

只有单个NameNode节点,使用SecondaryNameNode或BackupNode节点实时获取NameNode元数据信息,备份元数据。

hadoop-HDFS

商用拓扑

有两个NameNode节点,并使用ZooKeeper实现NameNode节点间的热切换。

hadoop-HDFS

命令行接口

HDFS提供了各种交互方式,例如通过Java API、HTTP、shell命令行的。命令行的交互主要通过hadoop fs来操作。例如:

hadoop fs -copyFromLocal // 从本地复制文件到HDFS
hadoop fs mkdir // 创建目录
hadoop fs -ls // 列出文件列表

Hadoop中,文件和目录的权限类似于POSIX模型,包括读、写、执行3种权限:

读权限(r):用于读取文件或者列出目录中的内容
写权限(w):对于文件,就是文件的写权限。目录的写权限指在该目录下创建或者删除文件(目录)的权限。
执行权限(x):文件没有所谓的执行权限,被忽略。对于目录,执行权限用于访问器目录下的内容。

每个文件或目录都有owner,group,mode三个属性:

owner:指文件的所有者
group:为权限组
mode:由所有者权限、文件所属的组中组员的权限、非所有者非组员的权限组成。

hadoop-HDFS

数据流(读写流程)

读文件

大致读文件的流程如下:

hadoop-HDFS

  1. 客户端传递一个文件Path给FileSystem的open方法

  2. DFS采用RPC远程获取文件最开始的几个block的datanode地址。Namenode会根据网络拓扑结构决定返回哪些节点(前提是节点有block副本),如果客户端本身是Datanode并且节点上刚好有block副本,直接从本地读取。

  3. 客户端使用open方法返回的FSDataInputStream对象读取数据(调用read方法)

  4. DFSInputStream(FSDataInputStream实现了改类)连接持有第一个block的、最近的节点,反复调用read方法读取数据

  5. 第一个block读取完毕之后,寻找下一个block的最佳datanode,读取数据。如果有必要,DFSInputStream会联系Namenode获取下一批Block 的节点信息(存放于内存,不持久化),这些寻址过程对客户端都是不可见的。

  6. 数据读取完毕,客户端调用close方法关闭流对象

在读数据过程中,如果与Datanode的通信发生错误,DFSInputStream对象会尝试从下一个最佳节点读取数据,并且记住该失败节点, 后续Block的读取不会再连接该节点

读取一个Block之后,DFSInputStram会进行检验和验证,如果Block损坏,尝试从其他节点读取数据,并且将损坏的block汇报给Namenode。

客户端连接哪个datanode获取数据,是由namenode来指导的,这样可以支持大量并发的客户端请求,namenode尽可能将流量均匀分布到整个集群。

Block的位置信息是存储在namenode的内存中,因此相应位置请求非常高效,不会成为瓶颈。

写文件

hadoop-HDFS

步骤分解

  1. 客户端调用DistributedFileSystem的create方法

  2. DistributedFileSystem远程RPC调用Namenode在文件系统的命名空间中创建一个新文件,此时该文件没有关联到任何block。 这个过程中,Namenode会做很多校验工作,例如是否已经存在同名文件,是否有权限,如果验证通过,返回一个FSDataOutputStream对象。 如果验证不通过,抛出异常到客户端。

  3. 客户端写入数据的时候,DFSOutputStream分解为packets(数据包),并写入到一个数据队列中,该队列由DataStreamer消费。

  4. DateStreamer负责请求Namenode分配新的block存放的数据节点。这些节点存放同一个Block的副本,构成一个管道。 DataStreamer将packet写入到管道的第一个节点,第一个节点存放好packet之后,转发给下一个节点,下一个节点存放 之后继续往下传递。

  5. DFSOutputStream同时维护一个ack queue队列,等待来自datanode确认消息。当管道上的所有datanode都确认之后,packet从ack队列中移除。

  6. 数据写入完毕,客户端close输出流。将所有的packet刷新到管道中,然后安心等待来自datanode的确认消息。全部得到确认之后告知Namenode文件是完整的。 Namenode此时已经知道文件的所有Block信息(因为DataStreamer是请求Namenode分配block的),只需等待达到最小副本数要求,然后返回成功信息给客户端。

Namenode如何决定副本存在哪个Datanode?

HDFS的副本的存放策略是可靠性、写带宽、读带宽之间的权衡。默认策略如下:

第一个副本放在客户端相同的机器上,如果机器在集群之外,随机选择一个(但是会尽可能选择容量不是太慢或者当前操作太繁忙的)

第二个副本随机放在不同于第一个副本的机架上。

第三个副本放在跟第二个副本同一机架上,但是不同的节点上,满足条件的节点中随机选择。

更多的副本在整个集群上随机选择,虽然会尽量避免太多副本在同一机架上。

副本的位置确定之后,在建立写入管道的时候,会考虑网络拓扑结构。下面是可能的一个存放策略:

hadoop-HDFS

这样选择很好的平衡了可靠性、读写性能

  • 可靠性:Block分布在两个机架上

  • 写带宽:写入管道的过程只需要跨越一个交换机

  • 读带宽:可以从两个机架中任选一个读取

HDFS内部特性

数据冗余

  • HDFS将每个文件存储成一系列数据块(Block),默认块大小为64MB(可配置)。

  • 为了容错,文件的所有数据块都会有副本(副本数量即复制因子,可配置)。

  • HDFS的文件都是一次性写入的,并且严格限制为任何时候都只有一个写用户。

副本存放

  • HDFS集群一般运行在多个机架上,不同机架上机器的通信需要通过交换机。

  • HDFS采用机架感知(Rack-aware)的策略来改进数据的可靠性、可用性和网络带宽的利用率。

  • 机架的错误远比节点的错误少,这个策略可以防止整个机架失效时数据丢失,提高数据的可靠性和可用性,又能保证性能。

副本选择

  • HDFS会尽量使用离程序最近的副本来满足用户请求,这样可以减少总带宽消耗和读延时。

  • HDFS的架构支持数据均衡策略。

心跳检测

  • NameNode周期性地从集群中的每个DataNode接受心跳包和块报告,收到心跳包说明该DataNode工作正常。

  • NameNode会标记最近没有心跳的DataNode为宕机,不会发给它们任何新的I/O请求。

  • NameNode会不断检测这些需要复制的数据块,并在需要的时候重新复制。

数据完整性检测

  • 多种原因可能造成从DataNode获取的数据块有损坏。

  • HDFS客户端软件实现了对HDFS文件内容的校验和检查(Checksum)。

  • DataNode获得的数据块对应的校验和隐藏文件中的不同,客户端就会判定数据块有损坏,将从其他DataNode获取该数据块的副本。

简单一致性模型、流式数据访问

  • HDFS的应用程序一般对文件实行一次写、多次读的访问模式。

  • 文件一旦创建、写入和关闭之后就不需要再更改了。

  • 这样就简化了数据一致性问题,高吞吐量的数据访问才成为可能;运行在HDFS上的应用主要以流式读为主,做批量处理;更注重数据访问的高吞吐量。

客户端缓存

  • 客户端创建文件的请求不是立即到达NameNode,HDFS客户端先把数据缓存到本地的一个临时文件,程序的写操作透明地重定向到这个临时文件。

  • 当这个临时文件累积的数据超过一个块的大小(64MB)时,客户端才会联系NameNode。

  • 如果NameNode在文件关闭之前死机,那么文件将会丢失。

  • 如果不采用客户端缓存,网络速度和拥塞都会对输出产生很大的影响。

MapReduce 定义

MapReduce 是一个分布式运算程序的编程框架,是用户开发“基于hadoop的数据分析应用”的核心框架,其核心功能是将用户编写的业务逻辑代码和自带默认组件整合成一个完整的分布式运算程序,并发运行在一个hadoop集群上。

MapReduce 产生缘由

为什么需要MapReduce?

  • 海量数据在单机上处理因为硬件资源限制,无法胜任。
  • 而一旦将单机版程序扩展到集群来分布式运行,将极大增加程序的复杂度和开发难度。
  • 引入MapReduce框架后,开发人员可以将绝大部分工作集中在业务逻辑的开发上,而将分布式计算中的复杂性交由框架来处理。

设想一个海量数据场景下的wordcount需求:

  • 单机版:内存受限,磁盘受限,运算能力受限
  • 分布式:文件分布式存储(HDFS)、运算逻辑需要至少分成2个阶段(一个阶段独立并发,一个阶段汇聚)、运算程序如何分发、程序如何分配运算任务(切片)、两阶段的程序如何启动?如何协调?、整个程序运行过程中的监控?容错?重试?

可见在程序由单机版扩成分布式时,会引入大量的复杂工作。

MapReduce与Yarn的关系

Yarn 是一个资源调度平台,负责为运算程序提供服务器运算资源,相当于一个分布式的操作系统平台。而MapReduce等运算程序则相当于运行于操作系统之上的应用程序。

YARN的重要概念:

  1. yarn并不清楚用户提交的程序的运行机制;

  2. yarn只提供运算资源的调度(用户程序向yarn申请资源,yarn就负责分配资源);

  3. yarn中的主管角色叫ResourceManager;

  4. yarn中具体提供运算资源的角色叫NodeManager;

  5. 这样一来,yarn其实就与运行的用户程序完全解耦,就意味着yarn上可以运行各种类型的分布式运算程序(MapReduce只是其中的一种),比如MapReduce、storm程序,spark程序,tez……;

  6. 所以,spark、storm等运算框架都可以整合在yarn上运行,只要他们各自的框架中有符合yarn规范的资源请求机制即可;

  7. Yarn就成为一个通用的资源调度平台,从此,企业中以前存在的各种运算集群都可以整合在一个物理集群上,提高资源利用率,方便数据共享。

MapReduce 工作原理

严格说起来MapReduce并不是一种算法, 而是一个计算思想。它由map和reduce两个阶段组成。

MapReduce 进程

为了提高开发效率,可以将分布式程序中的公共功能封装成框架,让开发人员可以将精力集中于业务逻辑。

而MapReduce就是这样一个分布式程序的通用框架,整体结构如下(在分布式运行时有三类实例进程):

  • MRAppMaster:负责整个程序的过程调度及状态协调
  • MapTask:负责map阶段的整个数据处理流程
  • ReduceTask:负责reduce阶段的整个数据处理流程

MapReduce 运行机制

hadoop

流程描述如下:

  1. 一个MR程序启动的时候,最先启动的是MRAppMaster,MRAppMaster启动后根据本次job的描述信息,计算出需要的MapTask实例数量,然后向集群申请机器启动相应数量的MapTask进程;

  2. MapTask进程启动之后,根据给定的数据切片范围进行数据处理,主体流程为:

    • 利用客户指定的inputformat来获取RecordReader读取数据,形成输入KV对;
    • 将输入KV对传递给客户定义的map()方法,做逻辑运算,并将map()方法输出的KV对收集到缓存;
    • 将缓存中的KV对按照K分区排序后不断溢写到磁盘文件。
  3. MRAppMaster监控到所有MapTask进程任务完成之后,会根据客户指定的参数启动相应数量的ReduceTask进程,并告知ReduceTask进程要处理的数据范围(数据分区);

  4. ReduceTask进程启动之后,根据MRAppMaster告知的待处理数据所在位置,从若干台MapTask运行所在机器上获取到若干个MapTask输出结果文件,并在本地进行重新归并排序,然后按照相同key的KV为一个组,调用客户定义的reduce()方法进行逻辑运算,并收集运算输出的结果KV,然后调用客户指定的outputformat将结果数据输出到外部存储。

我们来举个例子。

hadoop
上图是一个统计词频的任务。

  1. Hadoop将输入数据切成若干个分片,并将每个split(分割)交给一个map task(Map任务)处理。

  2. Mapping之后,相当于得出这个task里面,每个词以及它出现的次数。

  3. shuffle(拖移)将相同的词放在一起,并对它们进行排序,分成若干个分片。

  4. 根据这些分片,进行reduce(归约)。

  5. 统计出reduce task的结果,输出到文件。

在MapReduce里,为了完成上面这些过程,需要两个角色:JobTracker和TaskTracker。

hadoop

JobTracker用于调度和管理其它的TaskTracker。JobTracker可以运行于集群中任一台计算机上。TaskTracker 负责执行任务,必须运行于 DataNode 上。

现在这边给出一个简单的mapreduce实现示例:

用于统计输入文件中每个单词的出现次数。

  1. 导入必要的包:

    import java.io.IOException;
    import org.apache.hadoop.conf.Configuration;
    import org.apache.hadoop.fs.Path;
    import org.apache.hadoop.io.IntWritable;
    import org.apache.hadoop.io.LongWritable;
    import org.apache.hadoop.io.Text;
    import org.apache.hadoop.mapreduce.Job;
    import org.apache.hadoop.mapreduce.Mapper;
    import org.apache.hadoop.mapreduce.Reducer;
    import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
    import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
  2. 定义Mapper类:

    public static class MyMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
        protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException {
            String line = value.toString();
            // 将每行文本拆分为单词,然后发送到Reducer
            String[] words = line.split("\\s+");
            for (String word : words) {
                context.write(new Text(word), new IntWritable(1));
            }
        }
    }

    Mapper类的作用是将输入的文本数据拆分成单词,然后为每个单词输出一个键-值对(单词, 1)。

  3. 定义Reducer类:

    public static class MyReduce extends Reducer<Text, IntWritable, Text, IntWritable> {
        protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException {
            int sum = 0;
            // 对相同单词的出现次数进行累加
            for (IntWritable value : values) {
                sum += value.get();
            }
            // 输出单词和其出现的总次数
            context.write(key, new IntWritable(sum));
        }
    }

    Reducer类的作用是接收来自Mapper的键-值对,对相同键的值进行累加,然后输出单词和其总出现次数。

  4. 主函数(main方法):

    public static void main(String[] args) throws InterruptedException, IOException, ClassNotFoundException {
        Configuration conf = new Configuration();
        Job job = Job.getInstance(conf, "word count");
        job.setJarByClass(word.class);
    
        job.setMapperClass(MyMapper.class);
        job.setMapOutputKeyClass(Text.class);
        job.setMapOutputValueClass(IntWritable.class);
    
        job.setReducerClass(MyReduce.class);
        job.setOutputKeyClass(Text.class);
        job.setOutputValueClass(IntWritable.class);
    
        // 设置输入路径和输出路径
        FileInputFormat.addInputPath(job, new Path(args[0]));
        FileOutputFormat.setOutputPath(job, new Path(args[1]));
    
        // 提交作业并等待完成
        job.waitForCompletion(true);
    }

hadoop

在整个Hadoop架构中,计算框架起到承上启下的作用,一方面可以操作HDFS中的数据,另一方面可以被封装,提供Hive、Pig这样的上层组件的调用。

我们简单介绍一下其中几个比较重要的组件。

HBase:来源于Google的BigTable;是一个高可靠性、高性能、面向列、可伸缩的分布式数据库。

Hive:是一个数据仓库工具,可以将结构化的数据文件映射为一张数据库表,通过类SQL语句快速实现简单的MapReduce统计,不必开发专门的MapReduce应用,十分适合数据仓库的统计分析。

Pig:是一个基于Hadoop的大规模数据分析工具,它提供的SQL-LIKE语言叫Pig Latin,该语言的编译器会把类SQL的数据分析请求转换为一系列经过优化处理的MapReduce运算。

ZooKeeper:来源于Google的Chubby;它主要是用来解决分布式应用中经常遇到的一些数据管理问题,简化分布式应用协调及其管理的难度。

Ambari:Hadoop管理工具,可以快捷地监控、部署、管理集群。

Sqoop:用于在Hadoop与传统的数据库间进行数据的传递。

Mahout:一个可扩展的机器学习和数据挖掘库。

Hadoop的优点和应用

总的来看,Hadoop有以下优点:

高可靠性:这个是由它的基因决定的。它的基因来自Google。Google最擅长的事情,就是“垃圾利用”。Google起家的时候就是穷,买不起高端服务器,所以,特别喜欢在普通电脑上部署这种大型系统。虽然硬件不可靠,但是系统非常可靠。

高扩展性:Hadoop是在可用的计算机集群间分配数据并完成计算任务的,这些集群可以方便地进行扩展。说白了,想变大很容易。

高效性:Hadoop能够在节点之间动态地移动数据,并保证各个节点的动态平衡,因此处理速度非常快。

高容错性:Hadoop能够自动保存数据的多个副本,并且能够自动将失败的任务重新分配。这个其实也算是高可靠性。

低成本:Hadoop是开源的,依赖于社区服务,使用成本比较低。

基于这些优点,Hadoop适合应用于大数据存储和大数据分析的应用,适合于服务器几千台到几万台的集群运行,支持PB级的存储容量。

Hadoop的应用非常广泛,包括:搜索、日志处理、推荐系统、数据分析、视频图像分析、数据保存等,都可以使用它进行部署。

Hadoop是什么?

Hadoop是一个由Apache基金会所开发的分布式系统基础架构,是一个存储系统+计算框架的软件框架。主要解决海量数据存储与计算的问题,是大数据技术中的基石。Hadoop以一种可靠、高效、可伸缩的方式进行数据处理,用户可以在不了解分布式底层细节的情况下,开发分布式程序,用户可以轻松地在Hadoop上开发和运行处理海量数据的应用程序。

Hadoop能解决什么问题

  • 海量数据存储

    HDFS有高容错性的特点,并且设计用来部署在低廉的(low-cost)硬件上;而且它提供高吞吐量(High throughput)来访问数据,适合那些有着超大数据集(large data set)的应用程序,它由n台运行着DataNode的机器组成和1台(另外一个standby)运行NameNode进程一起构成。每个DataNode 管理一部分数据,然后NameNode负责管理整个HDFS 集群的信息(存储元数据)。

  • 资源管理,调度和分配

    Apache Hadoop YARN(Yet Another Resource Negotiator,另一种资源协调者)是一种新的 Hadoop 资源管理器,它是一个通用资源管理系统和调度平台,可为上层应用提供统一的资源管理和调度,它的引入为集群在利用率、资源统一管理和数据共享等方面带来了巨大好处。

Hadoop的由来

hadoop

Hadoop的核心架构

Hadoop的核心,说白了,就是HDFSMapReduce。HDFS为海量数据提供了存储,而MapReduce为海量数据提供了计算框架。

HDFS

hadoop

整个HDFS有三个重要角色:NameNode(名称节点)DataNode(数据节点)Client(客户机)

典型的主从架构,用TCP/IP通信

  • NameNode:Master节点(主节点),可以看作是分布式文件系统中的管理者,主要负责管理文件系统的命名空间、集群配置信息和存储块的复制等。NameNode会将文件系统的Meta-data存储在内存中,这些信息主要包括了文件信息、每一个文件对应的文件块的信息和每一个文件块在DataNode的信息等。

  • DataNode:Slave节点(从节点),是文件存储的基本单元,它将Block存储在本地文件系统中,保存了Block的Meta-data,同时周期性地将所有存在的Block信息发送给NameNode。

  • Client:切分文件;访问HDFS;与NameNode交互,获得文件位置信息;与DataNode交互,读取和写入数据。

还有一个Block(块)的概念:Block是HDFS中的基本读写单元;HDFS中的文件都是被切割为block(块)进行存储的;这些块被复制到多个DataNode中;块的大小(通常为64MB)和复制的块数量在创建文件时由Client决定。

MapReduce

MapReduce是一种分布式计算模型,它将大规模数据集(大于1TB)分成许多小数据块,然后在集群中的各个节点上进行并行处理,最后将结果汇总。MapReduce的计算过程可以分为两个阶段:Map阶段和Reduce阶段。

  • Map阶段:将输入数据切分成若干个小数据块,然后由多个Map任务并行处理,每个Map任务将处理结果输出为若干个键值对。

  • Reduce阶段:将Map阶段的输出结果按照键值对中的键进行分组,然后由多个Reduce任务并行处理,每个Reduce任务将处理结果输出为若干个键值对。

总结

Hadoop是一个分布式系统基础架构,主要解决海量数据存储与计算的问题。它的核心是HDFS和MapReduce,其中HDFS为海量数据提供了存储,而MapReduce为海量数据提供了计算框架。除此之外,Hadoop还有一个重要的组件——YARN,它是一个通用资源管理系统和调度平台,可为上层应用提供统一的资源管理和调度。

云计算的定义

即通过网络按需提供可动态伸缩的廉价计算服务。是与信息技术、软件、互联网相关的一种服务。

云计算是一种按使用量付费的模式,这种模式提供可用的、便捷的、按需的网络访问,进入可配置的计算机资源共享池(资源包括网络、服务器、存储、应用软件、服务),这些资源能够被快速提供。

云计算发展史

2006年3月,亚马逊(Amazon)推出弹性计算云(Elastic Compute Cloud;EC2)服务。

2006年8月9日,Google首席执行官埃里克·施密特(Eric Schmidt)在搜索引擎大会(SES San Jose 2006)首次提出“云计算”(Cloud Computing)的概念。Google“云端计算”源于Google工程师克里斯托弗·比希利亚所做的“Google 101”项目。

2007年10月,Google与IBM开始在美国大学校园推广云计算的计划。

2008年2月1日,IBM(NYSE: IBM)宣布将在中国无锡太湖新城科教产业园为中国的软件公司建立全球第一个云计算中心(Cloud Computing Center)。

2008年7月29日,雅虎、惠普和英特尔宣布一项联合研究计划,推出云计算研究测试床,推进云计算。

2008年8月3日,美国专利商标局网站信息显示,戴尔正在申请“云计算”(Cloud Computing)商标,此举旨在加强对这一未来可能重塑技术架构的术语的控制权。

2010年3月5日,Novell与云安全联盟(CSA)共同宣布一项供应商中立计划,名为“可信任云计算计划(Trusted Cloud Initiative)”。

2010年7月,美国国家航空航天局和包括Rackspace、AMD、Intel、戴尔等支持厂商共同宣布“OpenStack”开放源代码计划,微软在2010年10月表示支持OpenStack与Windows Server 2008 R2的集成;而Ubuntu已把OpenStack加至11.04版本中。

2011年2月,思科系统正式加入OpenStack,重点研制OpenStack的网络服务。

云计算的技术背景

云计算是并行计算(Parallel Computing)分布式计算(Distributed Computing)网格计算(Grid Computing)的发展,或者说是这些计算机科学概念的商业实现

云计算是虚拟化(Virtualization)效用计算(Utility Computing)IaaS(基础设施即服务)PaaS(平台即服务)SaaS(软件即服务)等技术混合演进、提升的结果

引言

本节我们将讨论如何处理事件,包括如何在虚拟节点中描述事件,如何把事件添加到 DOM 元素上,以及如何更新事件。我们先来解决第一个问题,即如何在虚拟节点中描述事件。事件可以视作一种特殊的属性,因此我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件。例如:

const vnode = {
  type: 'p',
  props: {
    // 使用 onXxx 描述事件
    onClick: () => {
      alert('clicked');
    }
  },
  children: 'text'
};

解决了事件在虚拟节点层面的描述问题后,我们再来看看如何将事件添加到 DOM 元素上。这非常简单,只需要在 patchProps 中调用 addEventListener 函数来绑定事件即可,如下面的代码所示:

function patchProps(el, key, prevValue, nextValue) {
  // 匹配以 on 开头的属性,视其为事件
  if (/^on/.test(key)) {
    // 根据属性名称得到对应的事件名称,例如 onClick ---> click
    const name = key.slice(2).toLowerCase();
    
    // 移除上一次绑定的事件处理函数
    prevValue && el.removeEventListener(name, prevValue);
    // 绑定新的事件处理函数
    el.addEventListener(name, nextValue);
  } else if (key === 'class') {
    // 省略部分代码(处理 class 属性的逻辑)
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码(处理其他属性的逻辑)
  } else {
    // 省略部分代码(处理其他属性的逻辑)
  }
}

事实上可以更为优化的事件更新机制,避免多次调用 removeEventListeneraddEventListener

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const name = key.slice(2).toLowerCase();
    let invoker = el.__vei || (el.__vei = {});

    if (nextValue) {
      if (!invoker[name]) {
        // 如果没有 invoker,则创建一个伪造的 invoker 函数
        invoker[name] = (e) => {
          invoker[name].value(e);
        };
      }
      
      // 将真正的事件处理函数赋值给 invoker 函数的 value 属性
      invoker[name].value = nextValue;

      // 绑定 invoker 函数作为事件处理函数
      el.addEventListener(name, invoker[name]);
    } else if (invoker[name]) {
      // 如果新的事件处理函数不存在,且之前绑定的 invoker 存在,则移除绑定
      el.removeEventListener(name, invoker[name]);
      invoker[name] = null;
    }
  } else if (key === 'class') {
    // 省略部分代码(处理 class 属性的逻辑)
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 省略部分代码(处理其他属性的逻辑)
  } else {
    // 省略部分代码(处理其他属性的逻辑)
  }
}

观察上面的代码,事件绑定主要分为两个步骤。先从 el._vei 中读取对应的 invoker,如果 invoker 不存在,则将伪造的 invoker 作为事件处理函数,并将它缓存到el._vei 属性中。

把真正的事件处理函数赋值给 invoker.value 属性,然后把伪造的 invoker 函数作为事件处理函数绑定到元素上。可以看到,当事件触发时,实际上执行的是伪造的事件处理函数,在其内部间接执行了真正的事件处理函数 invoker.value(e)。

当更新事件时,由于 el._vei 已经存在了,所以我们只需要将invoker.value 的值修改为新的事件处理函数即可。

这样,在更新事件时可以避免一次 removeEventListener 函数的调用,从而提升了性能。实际上,伪造的事件处理函数的作用不止于此,它还能解决事件冒泡与事件更新之间相互影响的问题。但目前的实现仍然存在问题。现在我们将事件处理函数缓存在el._vei 属性中,问题是,在同一时刻只能缓存一个事件处理函数。这意味着,如果一个元素同时绑定了多种事件,将会出现事件覆盖的现象。

const vnode = {
  type: 'p',
  props: {
    // 使用 onXxx 描述事件
    onClick: () => {
      alert('clicked');
    },
    onContextmenu: () => {
      alert('contextmenu');
    }
  },
  children: 'text'
};

// 假设 renderer 是你的渲染器对象
renderer.render(vnode, document.querySelector('#app'));

当渲染器尝试渲染这上面代码中给出的 vnode 时,会先绑定click 事件,然后再绑定 contextmenu 事件。后绑定的contextmenu 事件的处理函数将覆盖先绑定的 click 事件的处理函
数。为了解决事件覆盖的问题,我们需要重新设计 el._vei 的数据结构。我们应该将 el._vei 设计为一个对象,它的键是事件名称,它的值则是对应的事件处理函数,这样就不会发生事件覆盖的现象了.

根据你提供的代码片段,这段代码主要是用于处理 DOM 元素的属性更新,其中包括事件的绑定和解绑逻辑。在这个代码中,它使用了一个 el._vei 的对象来缓存事件处理函数。下面是你提供的代码的一些修正:

function patchProps(el, key, prevValue, nextValue) {
  if (/^on/.test(key)) {
    const invokers = el._vei || (el._vei = {});
    const name = key.slice(2).toLowerCase();
    let invoker = invokers[name];

    if (nextValue) {
      if (!invoker) {
        invoker = el._vei[name] = (e) => {
          if (Array.isArray(invoker.value)) {
            invoker.value.forEach(fn => fn(e));
          } else {
            invoker.value(e);
          }
        };
      }

      invoker.value = nextValue;
      el.addEventListener(name, invoker);
    } else if (invoker) {
      el.removeEventListener(name, invoker);
      el._vei[name] = null;
    }
  } else if (key === 'class') {
    // 处理 class 属性的逻辑
  } else if (shouldSetAsProps(el, key, nextValue)) {
    // 处理其他属性的逻辑
  } else {
    // 处理其他属性的逻辑
  }
}

在这段代码中,我们修改了 invoker 函数的实现。当 invoker函数执行时,在调用真正的事件处理函数之前,要先检查invoker.value 的数据结构是否是数组,如果是数组则遍历它,并逐个调用定义在数组中的事件处理函数。

前言

Vue.js模板功能强大,几乎可以满足我们在应用程序中所需的一切。但是,有一些场景下,比如基于输入或插槽值创建动态组件,render函数可以更好地满足这些用例。

那些来自React世界的开发者可能对render函数非常熟悉。通常在JSX中使用它们来构建React组件。虽然Vue渲染函数也可以用JSX编写,但我们将继续使用原始JS,有助于我们可以更轻松地了解Vue组件系统的基础。。

每个Vue组件都实现了一个render函数。大多数时候,该函数将由Vue编译器创建。当我们在组件上指定模板时,该模板的内容将由Vue编译器处理,编译器最终将返回render函数。渲染函数本质上返回一个虚拟DOM节点,该节点将被Vue在浏览器DOM中渲染。

现在又引出了虚拟DOM的概念, 虚拟DOM到底是什么?

虚拟文档对象模型(或”DOM”)允许Vue在更新浏览器之前在其内存中渲染组件。 这使一切变得更快,同时也避免了DOM重新渲染的高昂成本。因为每个DOM节点对象包含很多属性和方法,因此使用虚拟DOM预先在内存进行操作,可以省去很多浏览器直接创建DOM节点对象的开销。

Vue更新浏览器DOM时,会将更新的虚拟DOM与上一个虚拟DOM进行比较,并仅使用已修改的部分更新实际DOM。这意味着更少的元素更改,从而提高了性能。Render函数返回虚拟DOM节点,在Vue生态系统中通常称为VNode,该接口是允许Vue在浏览器DOM中写入这些对象的接口。它们包含使用Vue所需的所有信息。

vue-render

挂载子节点和元素的属性

vnode.children的值是字符串类型时,会把它设置为元素的文本内容。一个元素除了具有文本子节点外,还可以包含其他元素子节点,并且子节点可以是很多个。为了描述元素的子节点,我们需要将vnode.children定义为数组:

const vnode = {
  type: 'div',
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
};

上面这段代码描述的是“一个div标签具有一个子节点,且子节点是p标签”。可以看到,vnode.children是一个数组,它的每一个元素都是一个独立的虚拟节点对象。这样就形成了树型结构,即虚拟DOM树。

为了完成子节点的渲染,我们需要修改mountElement函数,如下面的代码所示:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  if (typeof vnode.children === 'string') {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    // 如果`children`是数组,则遍历每一个子节点,并调用`patch`函数挂载它们
    vnode.children.forEach(child => {
      patch(null, child, el);
    });
  }
  insert(el, container);
}

在上面这段代码中,我们增加了新的判断分支。使用Array.isArray函数判断vnode.children是否是数组,如果是数组,则循环遍历它,并调用patch函数挂载数组中的虚拟节点。在挂载子节点时,需要注意以下两点:

  1. 传递给patch函数的第一个参数是null。因为是挂载阶段,没有旧vnode,所以只需要传递null即可。这样,当patch函数执行时,就会递归地调用mountElement函数完成挂载。

  2. 传递给patch函数的第三个参数是挂载点。由于我们正在挂载的子元素是div标签的子节点,所以需要把刚刚创建的div元素作为挂载点,这样才能保证这些子节点挂载到正确位置。

完成了子节点的挂载后,我们再来看看如何用vnode描述一个标签的属性,以及如何渲染这些属性。我们知道,HTML标签有很多属性,其中有些属性是通用的,例如idclass等,而有些属性是特定元素才有的,例如form元素的action属性。实际上,渲染一个元素的属性比想象中要复杂,不过我们仍然秉承一切从简的原则,先来看看最基本的属性处理。

为了描述元素的属性,我们需要为虚拟DOM定义新的vnode.props字段,如下面的代码所示:

const vnode = {
  type: 'div',
  props: {
    id: 'foo'
  },
  children: [
    {
      type: 'p',
      children: 'hello'
    }
  ]
};

vnode.props是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值。这样,我们就可以通过遍历props对象的方式,把这些属性渲染到对应的元素上,如下面的代码所示:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略children的处理

  // 如果`vnode.props`存在才处理它
  if (vnode.props) {
    // 遍历`vnode.props`
    for (const key in vnode.props) {
      // 调用`setAttribute`将属性设置到元素上
      el.setAttribute(key, vnode.props[key]);
    }
  }

  insert(el, container);
}

在这段代码中,我们首先检查了vnode.props字段是否存在,如果存在则遍历它,并调用setAttribute函数将属性设置到元素上。实际上,除了使用setAttribute函数为元素设置属性之外,还可以通过DOM对象直接设置:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);
  // 省略children的处理

  if (vnode.props) {
    for (const key in vnode.props) {
      // 直接设置
      el[key] = vnode.props[key];
    }
  }

  insert(el, container);
}

在这段代码中,我们没有选择使用setAttribute函数,而是直接将属性设置在DOM对象上,即el[key] = vnode.props[key]。实际上,无论是使用setAttribute函数,还是直接操作DOM对象,都存在缺陷。如前所述,为元素设置属性比想象中要复杂得多。不过,在讨论具体有哪些缺陷之前,我们有必要先搞清楚两个重要的概念:HTML Attributes和DOM Properties。

当我们处理元素属性时,有两种主要的方式:使用HTML Attributes和DOM Properties。这两者在概念上有些许不同:

  1. HTML Attributes:

    • HTML Attributes是在HTML标签中定义的属性,例如idclasssrc等。
    • 通过setAttribute方法可以设置HTML Attributes。
    • HTML Attributes的值始终是字符串。
  2. DOM Properties:

    • DOM Properties是DOM对象上的属性,例如element.idelement.classNameelement.src等。
    • 直接操作DOM对象可以设置DOM Properties。
    • DOM Properties的值可以是字符串、数字、布尔值等,具体取决于属性的类型。

HTML Attributes和DOM Properties

在处理元素属性时,我们需要明确HTML Attributes和DOM Properties之间的区别。

如果我们使用setAttribute方法设置属性,那么属性会被设置为HTML Attributes。如果我们直接操作DOM对象的属性,属性会被设置为DOM Properties。

现在,我们来讨论一下这两种方式存在的问题:

  1. 属性值类型转换问题:

    • 当我们使用setAttribute方法设置属性时,属性的值始终被转换为字符串。这就意味着,如果我们将一个数字或布尔值赋给属性,它们都会被转换为字符串。例如,element.setAttribute('value', 42)会将值转换为字符串'42'
  2. 布尔属性问题:

    • HTML中的一些属性是布尔属性,例如checkeddisabled等。对于这些属性,如果存在就表示为true,不存在就表示为false
    • 当我们使用setAttribute方法设置布尔属性时,不论属性值是什么,都会被视为存在。例如,element.setAttribute('disabled', 'false')会使元素具有disabled属性,即使值是字符串'false'

考虑到这些问题,最好的做法是尽量使用DOM Properties而不是HTML Attributes来设置元素的属性。这样可以避免类型转换问题和布尔属性问题,确保属性被正确设置。

首先,HTML Attributes指的是定义在HTML标签上的属性,例如id="my-input"type="text"value="foo"。当浏览器解析这段HTML代码后,会创建一个与之相符的DOM元素对象,我们可以通过JavaScript代码来读取该DOM对象:

const el = document.querySelector('#my-input');

现在来说一下DOM Properties。许多HTML Attributes在DOM对象上有与之同名的DOM Properties,例如id="my-input"对应el.idtype="text"对应el.typevalue="foo"对应el.value等。但是,DOM Properties与HTML Attributes的名字并不总是一模一样的,例如:

<div class="foo"></div>

class="foo"对应的DOM Properties则是el.className。另外,并不是所有HTML Attributes都有与之对应的DOM Properties,例如:

<div aria-valuenow="75"></div>

aria-*类的HTML Attributes就没有与之对应的DOM Properties。

类似地,也不是所有DOM Properties都有与之对应的HTML Attributes,例如可以用el.textContent来设置元素的文本内容,但并没有与之对应的HTML Attributes来完成同样的工作。

HTML Attributes的值与DOM Properties的值之间是有关联的。例如下面的HTML片段:

<div id="foo"></div>

这个片段描述了一个具有id属性的div标签。其中,id="foo"对应的DOM Properties是el.id,并且值为字符串'foo'。我们把这种HTML Attributes与DOM Properties具有相同名称(即id)的属性看作直接映射。

但并不是所有HTML Attributes与DOM Properties之间都是直接映射的关系,例如:

<input value="foo" />

这是一个具有value属性的input标签。如果用户没有修改文本框的内容,那么通过el.value读取对应的DOM Properties的值就是字符串'foo'。而如果用户修改了文本框的值,那么el.value的值就是当前文本框的值。例如,用户将文本框的内容修改为'bar',那么:

console.log(el.value); // 'bar'

但如果运行下面的代码,会发生“奇怪”的现象:

console.log(el.getAttribute('value')); // 仍然是 'foo'
console.log(el.value); // 'bar'

可以发现,用户对文本框内容的修改并不会影响el.getAttribute('value')的返回值,这个现象蕴含着HTML Attributes所代表的意义。实际上,HTML Attributes的作用是设置与之对应的DOM Properties的初始值。一旦值改变,那么DOM Properties始终存储着当前值,而通过getAttribute函数得到的仍然是初始值。

但我们仍然可以通过el.defaultValue来访问初始值,如下面的代码所示:

el.getAttribute('value'); // 仍然是 'foo'
el.value; // 'bar'
el.defaultValue; // 'foo'

这说明一个HTML Attributes可能关联多个DOM Properties。例如在上例中,value="foo"el.valueel.defaultValue都有关联。

虽然我们可以认为HTML Attributes是用来设置与之对应的DOM Properties的初始值的,但有些值是受限制的,就好像浏览器内部做了默认值校验。如果你通过HTML Attributes提供的默认值不合法,那么浏览器会使用内建的合法值作为对应DOM Properties的默认值,例如:

<input type="foo" />

我们知道,为<input/>标签的type属性指定字符串'foo'是不合法的,因此浏览器会矫正这个不合法的值。所以当我们尝试读取el.type时,得到的其实是矫正后的值,即字符串'text',而非字符串'foo'

console.log(el.type); // 'text'

从上述分析来看,HTML Attributes与DOM Properties之间的关系很复杂,但实际上我们只需要记住一个核心原则:HTML Attributes的作用是设置与之对应的DOM Properties的初始值。

如何正确地设置元素属性

在上文中,我们讨论了在Vue.js单文件组件的模板中,HTML Attributes和DOM Properties的设置方式。在普通的HTML文件中,浏览器会自动解析HTML Attributes并设置相应的DOM Properties。然而,在Vue.js的模板中,需要框架手动处理这些属性的设置。

首先,我们以一个禁用的按钮为例,如下所示的HTML代码:

<button disabled>Button</button>

浏览器会自动将这个按钮设置为禁用状态,并将其对应的DOM Properties el.disabled的值设置为true。但是,如果同样的代码出现在Vue.js的模板中,情况就会有所不同。

在Vue.js的模板中,HTML模板会被编译成虚拟节点(vnode),其中props.disabled的值是一个空字符串。如果直接使用setAttribute函数设置属性,会导致意外的效果,即按钮被禁用。例如,以下模板:

<button disabled="false">Button</button>

对应的虚拟节点为:

const button = {
  type: 'button',
  props: {
    disabled: false
  }
};

如果使用setAttribute函数将属性值设置为空字符串,实际上相当于:

el.setAttribute('disabled', '');

而按钮的el.disabled属性是布尔类型的,不关心具体的HTML Attributes的值是什么,只要disabled属性存在,按钮就会被禁用。因此,渲染器不应该总是使用setAttribute函数将vnode.props对象中的属性设置到元素上。

为了解决这个问题,我们可以优先设置元素的DOM Properties,但当值为空字符串时,需要手动将其矫正为true。以下是一个具体的实现示例:

function mountElement(vnode, container) {
  const el = createElement(vnode.type);

  if (vnode.props) {
    for (const key in vnode.props) {
      if (key in el) {
        const type = typeof el[key];
        const value = vnode.props[key];
        if (type === 'boolean' && value === '') {
          el[key] = true;
        } else {
          el[key] = value;
        }
      } else {
        el.setAttribute(key, vnode.props[key]);
      }
    }
  }

  insert(el, container);
}

在上述代码中,我们检查每个vnode.props中的属性,看看是否存在对应的DOM Properties。如果存在,优先设置DOM Properties。同时,对布尔类型的DOM Properties做了值的矫正,即当要设置的值为空字符串时,将其矫正为布尔值true。如果vnode.props中的属性没有对应的DOM Properties,则仍然使用setAttribute函数完成属性的设置。

然而,上述实现仍然存在问题。有些DOM Properties是只读的,例如el.form。为了解决这个问题,我们可以添加一个辅助函数shouldSetAsProps,用于判断是否应该将属性作为DOM Properties设置。如果属性是只读的,或者需要特殊处理,就应该使用setAttribute函数来设置属性。

最后,为了使属性设置操作与平台无关,我们将属性设置相关的操作提取到渲染器选项中。以下是相应的代码示例:

const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag);
  },
  setElementText(el, text) {
    el.textContent = text;
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor);
  },
  patchProps(el, key, prevValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === 'boolean' && nextValue === '') {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  }
});

mountElement函数中,只需要调用patchProps函数,并为其传递相应的参数即可。这样,我们就将属性相关的渲染逻辑从渲染器的核心中抽离出来,使其更加可维护和灵活。

前言

在 Vue.js 中,很多功能依赖渲染器来实现,例如 Transition组件、Teleport 组件、Suspense 组件,以及 template ref 和自定义指令等。

另外,渲染器也是框架性能的核心,渲染器的实现直接影响框架的性能。Vue.js 3 的渲染器不仅仅包含传统的 Diff 算法,它还独创了快捷路径的更新方式,能够充分利用编译器提供的信息,大大提升了更新性能。

在Vue.js中,渲染器(renderer)是负责执行渲染任务的组件。在浏览器平台上,它将虚拟DOM渲染为真实DOM元素。渲染器不仅可以渲染真实DOM元素,还是框架跨平台能力的关键。在设计渲染器时,需要考虑其可自定义的能力。

渲染器的基本概念及其含义

在实现一个最基本的渲染器之前,我们要先了解几个基本的概念:

在Vue.js中,渲染器(renderer)是用来执行渲染任务的组件。在浏览器平台上,它将虚拟DOM渲染为真实DOM元素。以下是渲染器的基本概念及其含义:

渲染器(Renderer)

渲染器是负责将虚拟DOM(或虚拟节点)渲染为特定平台上的真实元素的组件。在浏览器平台上,渲染器会将虚拟DOM渲染为真实的DOM元素。

虚拟DOM(vnode)

虚拟DOM(也称为虚拟节点,简写为vnode)是一个树型结构,类似于真实DOM,由各种节点组成。渲染器的任务是将虚拟DOM渲染为真实的DOM元素。

挂载(Mounting)

挂载是指将虚拟DOM渲染为真实DOM元素并将其添加到指定的挂载点上。在Vue.js中,组件的mounted钩子函数就是在挂载完成时触发,此时可以访问到真实DOM元素。

容器(Container)

容器是指用来指定挂载位置的DOM元素。渲染器会将虚拟DOM渲染为真实DOM元素并添加到指定的容器内。在渲染器的render函数中,通常会传入一个容器参数,表示将虚拟DOM挂载到哪个DOM元素上。

渲染器的创建与使用

渲染器的创建通常使用createRenderer函数,该函数返回一个包含渲染和激活(hydrate)函数的对象。激活函数在同构渲染时使用,将虚拟DOM激活为已有的真实DOM元素。以下是渲染器的创建和使用示例:

function createRenderer() {
  function render(vnode, container) {
    // 渲染逻辑
  }

  function hydrate(vnode, container) {
    // 激活逻辑
  }

  return {
    render,
    hydrate
  };
}

const { render, hydrate } = createRenderer();
// 首次渲染
render(vnode, document.querySelector('#app'));
// 同构渲染
hydrate(vnode, document.querySelector('#app'));

上面的代码片中通过createRenderer函数创建了一个渲染器对象,包含了renderhydrate函数。render函数用于将虚拟DOM渲染为真实DOM元素,而hydrate函数用于将虚拟DOM激活为已有的真实DOM元素。

好的,现在我们已经对渲染器有了一个比较基础的认识,下面来一步步深入了解一下:

渲染器的实现可以通过如下的函数来表示,其中domString是待渲染的HTML字符串,container是挂载点的DOM元素:

function renderer(domString, container) {
  container.innerHTML = domString;
}

使用渲染器的示例:

renderer('<h1>Hello</h1>', document.getElementById('app'));

上述代码将<h1>Hello</h1>插入到id为app的DOM元素内。渲染器不仅可以渲染静态字符串,还可以渲染动态拼接的HTML内容:

let count = 1;
renderer(`<h1>${count}</h1>`, document.getElementById('app'));

如果count是一个响应式数据,那么可以使用响应系统来自动化整个渲染过程。首先,定义一个响应式数据count,然后在副作用函数内调用渲染器函数进行渲染:

const count = ref(1);

effect(() => {
  renderer(`<h1>${count.value}</h1>`, document.getElementById('app'));
});

count.value++;

在上述代码中,count是一个ref响应式数据。当修改count.value的值时,副作用函数会重新执行,完成重新渲染,最终渲染到页面的内容是<h1>2</h1>

这里使用了Vue 3提供的@vue/reactivity包中的响应式API,通过<script>标签引入:

<script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

上述代码中给出了render函数的基本实现,下面将其执行流程进行详细分析。假设我们连续三次调用renderer.render函数来执行渲染:

const renderer = createRenderer();

// 首次渲染
renderer.render(vnode1, document.querySelector('#app'));
// 第二次渲染
renderer.render(vnode2, document.querySelector('#app'));
// 第三次渲染
renderer.render(null, document.querySelector('#app'));

在首次渲染时,渲染器将vnode1渲染为真实DOM,并将vnode1存储到容器元素的container.vnode属性中,作为旧vnode。

在第二次渲染时,旧的vnode存在(即container.vnode中有值),此时渲染器将vnode2作为新vnode,将新旧vnode一同传递给patch函数进行打补丁。

在第三次渲染时,新的vnode的值为null,即不渲染任何内容。但是此时容器中渲染的是vnode2所描述的内容,所以渲染器需要清空容器。在上面的代码中,使用container.innerHTML = ''来清空容器。需要注意的是,这种清空容器的方式并不是最佳实践,但在这里仅用于演示目的。

关于patch函数,它是整个渲染器的核心入口,接收三个参数:旧vnode n1、新vnode n2和容器 container。在首次渲染时,旧vnode n1undefined,表示挂载动作。patch函数不仅用于打补丁,也可以执行挂载动作。

自定义渲染器

自定义渲染器的实现是通过抽象核心渲染逻辑,使其不再依赖于特定平台的API。以下是自定义渲染器的实现示例代码,使用配置项来实现平台无关的渲染:

// 创建渲染器函数,接收配置项作为参数
function createRenderer(options) {
  // 从配置项中获取操作 DOM 的 API
  const { createElement, insert, setElementText } = options;

  // 定义挂载元素的函数
  function mountElement(vnode, container) {
    // 调用 createElement 函数创建元素
    const el = createElement(vnode.type);
    // 如果子节点是字符串,调用 setElementText 设置文本内容
    if (typeof vnode.children === 'string') {
      setElementText(el, vnode.children);
    }
    // 调用 insert 函数将元素插入到容器内
    insert(el, container);
  }

  // 定义打补丁函数
  function patch(n1, n2, container) {
    // 实现打补丁逻辑,这部分内容在示例中省略
  }

  // 定义渲染函数,接收虚拟节点和容器作为参数
  function render(vnode, container) {
    // 如果旧虚拟节点存在,执行打补丁逻辑,否则执行挂载逻辑
    if (container.vnode) {
      patch(container.vnode, vnode, container);
    } else {
      mountElement(vnode, container);
    }
    // 将当前虚拟节点存储到容器的 vnode 属性中
    container.vnode = vnode;
  }

  // 返回渲染函数
  return render;
}

// 创建自定义渲染器的配置项
const customRendererOptions = {
  // 用于创建元素
  createElement(tag) {
    console.log(`创建元素 ${tag}`);
    // 在实际应用中,可以返回一个自定义的对象,模拟DOM元素
    return { type: tag };
  },
  // 用于设置元素的文本节点
  setElementText(el, text) {
    console.log(`设置 ${JSON.stringify(el)} 的文本内容:${text}`);
    // 在实际应用中,设置对象的文本内容
    el.textContent = text;
  },
  // 用于在给定的 parent 下添加指定元素
  insert(el, parent, anchor = null) {
    console.log(`${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)}`);
    // 在实际应用中,将 el 插入到 parent 内
    parent.children = el;
  },
};

// 使用自定义渲染器的配置项创建渲染函数
const customRenderer = createRenderer(customRendererOptions);

// 创建一个虚拟节点描述 <h1>hello</h1>
const vnode = {
  type: 'h1',
  children: 'hello',
};

// 使用一个对象模拟挂载点
const container = { type: 'root' };

// 使用自定义渲染器渲染虚拟节点到挂载点
customRenderer(vnode, container);

上面的代码片我们通过createRenderer函数创建了一个自定义渲染器,并通过配置项(customRendererOptions)传递操作DOM的API。渲染器在执行时,根据配置项中的API来完成相应的操作。通过这种方式,我们实现了一个通用的、不依赖于特定平台的渲染器。在实际应用中,可以根据不同的平台需求,通过配置不同的API,实现跨平台的渲染能力。