carry

carry 查看完整档案

北京编辑  |  填写毕业院校  |  填写所在公司/组织 segmentfault.com/u/carry_5f0d0ff2e088e 编辑
编辑

学无止境

个人动态

carry 发布了文章 · 10月12日

大前端进阶-如何构建组件库

在日常开发过程中,构建组件库是必不可少的一环,此篇文章就是描述如何搭建一个完整的组件库,解决组件库开发发布过程中的如下问题:

  1. 如何在最少的依赖下快速开发一个vue组件。
  2. 如何将所有的包放置在一个git仓库内。
  3. 如何将git仓库内的所有包一键发布。
  4. 如何管理所有包的依赖,减少包的体积。
  5. 如何快速创建组件示例。
  6. 如何打包组件,webpack?

快速原型开发

开发组件和开发项目是不一样的,在开发组件的时候,我们希望能够有一种工具能够快速针对某个vue文件搭建开发环境,并且在发布的时候能够对其进行打包编译,此时我们可以使用@vue/cli-service-global。

  • 全局安装

此包必须全局安装:

npm install -g @vue/cli-service-global
  • 创建vue文件

在根目录下创建App.vue文件:

<template>
  <h1>Hello!</h1>
</template>
  • 启动开发服务器

在命令行中执行:

vue serve

入口可以是 main.js、index.js、App.vue 或 app.vue 中的一个。你也可以显式地指定入口文件:

vue serve App.vue
  • 执行打包
vue build

打包完成后,打包结果会放到dist目录下, 默认情况下,会打包生成一个应用,该应用包含html和资源文件,可以直接部署为静态站点。但是通常情况下,我们需要将组件打包成一个库,以便发布后供项目使用。

打包成库需要指定构建目标:

vue build --target lib

添加构建目标后,执行打包,dist目录中包含各种规范的js文件和一个demo示例html。

目前为止,快速开发vue组件已经完成,我们可以快乐的开发各种组件,但是,当所需开发的组件慢慢变多之后,文件的组织方式成为我们需要考虑的事情。

可以想到有以下三种方式组织文件结构:

  1. 每一个组件都是一个单独的仓库。
  2. 一个仓库中包含多个组件vue文件,作为一个包发布。
  3. 一个仓库中包含多个组件包,每个组件包单独发布。

第一种方式,每一个组件都是一个单独仓库,虽然有利于组件开发,但是组件维护起来比较麻烦。组件越多,需要维护的仓库也就越多,当其中部分组件依赖的如lodash需要升级时,我们需要一个个进行升级,比较麻烦。

第二种方式,将所有的组件作为一个包发布,虽然维护比较方便,但是发布后,别人只想使用其中的一个组件时,会需要把整个组件库引入,如果不提供按需加载,那么会造成项目中引入很多不必要的代码。

第三种方式可参考下文。

monorepo

当我们查看vue3源码时,可以看到,仓储结构如下:

packages
├── compiler-core
    ├──_tests_ #单元测试
    ├──src #源文件目录
    ├──package.json
├── compiler-dom
    ├──_tests_ #单元测试
    ├──src #源文件目录
    ├──package.json
package.json

这个就是典型的monorepo,monorepo是项目代码的一种管理方式,指在一个仓库中管理多个模块/包。

monorepo追求的是在一个仓库中管理多个模块,每个模块有独立的package.json管理各自依赖,同时在项目根目录下可以通过命令安装或升级模块依赖,并提供了一个模块共享的node_modules。

yarn workspace

yarn workspace 是实现monorepo的一种方式。

使用yarn workspace要求在根目录的package.json中添加如下属性:

{
    "private": true,
    "workspaces": ["packages/*"]
}

private属性指定根目录是私有的,不会被发布工具发布到npm上。
workspace属性指定组件所在文件夹,支持通配符。

修改完package.json之后,按照vue-next的项目结构在packages文件夹下创建input测试组件。

假设,自定义的input组件依赖dayjs包,可以在根目录下执行如下命令安装:

yarn workspace m-input add dayjs

其中m-input并不是packages下组件文件夹的名称,而是组件文件夹下package.json中的name属性值。

安装完成后,dayjs会自动添加到input组件的package.json下,但是包下载到了根目录下的node_modules文件夹中,这样做可以更好的管理多组件包的依赖。如果当前组件依赖的包版本和其他组件依赖的包版本不一样,如其他组件依赖lodash@4,当前组件依赖lodash@3, 此时依赖包会被下载到当前组件文件夹下的node_modules中。

通过yarn workspace可以执行某个组件下的npm scripts,如给input组件添加一个build命令,可以在根目录下通过如下命令执行build:

yarn workspace m-input run build

对于build这种命令,几乎所有组件都需要,那么yarn workspace提供了一个快捷命令,可以一键执行所有组件包的build命令:

yarn workspaces run build

storybook

目前为止,仓库的整体文件结构和组件库的依赖包管理都已经完成了,可以愉快的开发组件了,当组件开发完成后,一般开发人员都会编写相应的使用文档,文档中包含相应的使用示例.

storybook是可视化的组件管理展示平台,支持在隔离的开发环境中,以交互式的方式展示组件,支持vue,react等。

安装使用:

npx -p @storybook/cli sb init --type vue
yarn add vue -W
yarn add vue-loader vue-template-compiler --dev -W

修改配置:

安装完成之后,在根目录的.storybook文件夹下存放着storybook使用的所有配置文件,修改main.js中stories属性,将其指向packages所有组件下的.stories.js文件。

"stories": [
    "../packages/**/*.stories.mdx",
    "../packages/**/*.stories.@(js|jsx|ts|tsx)"
]

添加组件示例:

在input组件包中添加Input.stories.js文件:

import MInput from './index'
export default {
    title: 'MInput',
    component: MInput
};

export const Text = () => ({
    components: { MInput },
    template: '<m-input />',
});

export const Password = () => ({
    components: { MInput },
    template: '<m-input type="password" placeholder="请输入密码"/>',
});

其中默认导出是storybook页面左侧导航栏,每一个具名导出都是一个样例。

最终执行yarn storybook,打开站点:

image.png

lerna

lerna 是babel团队开源的用于管理多包仓库的工具,也可以用于实现monorepo。

安装lerna:

npm install lerna -g

初始化lerna:

lerna init

会在项目根目录下添加lerna.json配置文件。

可以使用lerna管理项目依赖:

如果当前form自定义组件依赖input自定义组件,可以使用:

lerna add input --scope=form

还可以使用import命令导入本地包:

lerna import <path-to-external-repository>

通过exec和run执行包里面的相关命令

lerna run --scope my-component test
lerna exec -- rm -rf ./node_modules

通过clean命令一键清除所有包的node_modules目录:

lerna clean

learn最主要的功能是一键发布所有包的npm上:

lerna publish

发布包到npm需要登录,可以通过npm whoami查看当前登录用户,通过npm login进行登录。

单元测试

单元测试是组件化开发中必不可少的部分

安装依赖:

npm i jest @vue/test-utils vue-jest babel-jest -D
  1. 添加jest配置文件jest.config.js
module.exports = {
    "testMatch": ["**/_tests_/**/*.[jt]s?(x)"],
    "moduleFileExtensions": [
        "js",
        "json",
        // 告诉 Jest 处理 `*.vue` 文件
        "vue"
    ],
    "transform": {
        // 用 `vue-jest` 处理 `*.vue` 文件
        ".*\\.(vue)$": "vue-jest",
        // 用 `babel-jest` 处理 js
        ".*\\.(js)$": "babel-jest"
    }
}
  1. 添加babel配置文件babel.config.js
module.exports = {
  presets: [
    [
      '@babel/preset-env'
    ]
  ]
}
  1. 添加测试命令

"test": "jest"

  1. 添加测试文件

在组件包的_tests_文件夹下添加相关js文件,如input包下面添加input.test.js

import input from '../src/index.js'
import { mount } from '@vue/test-utils'

describe('m-input', () => {
  test('input-text', () => {
    const wrapper = mount(input)
    expect(wrapper.html()).toContain('input type="text"')
  })
})
  1. 执行测试命令
yarn test

测试可以在命令行中看到单元测试执行结果:

image.png

rollup打包

rollup是一个基于ESM的模块打包工具,和webpack相比,其打包结果更小,因此适合打包框架或者组件库。

安装必须的依赖:

npm i rollup rollup-plugin-terser rollup-plugin-vue@5.1.9 vue-template-compiler -D

需要注意的是安装vue时需要指定版本,否则会安装vue3。

  • 单组件打包
  1. 添加配置文件

在组件中添加rollup.config.js文件,该文件是rollup打包的配置文件,指定起始文件,输出文件位置及格式,插件。

import { terser } from 'rollup-plugin-terser'
import vue from 'rollup-plugin-vue'

module.exports = {
    input: 'src/index.js',
    output: [
        {
            file: 'dist/index.js',
            format: 'es'
        }
    ],
    plugins: [
        vue({
            css: true,
            compileTemplate: true
        }),
        terser()
    ]
}
  1. 添加可执行命令

在package.json文件的scripts属性下添加打包命令:

"build": "rollup -c"

-c指的是使用当前项目目录下的配置文件rollup.config.js

  1. 执行命令
yarn build

执行完毕之后,可以看到打包结果。

  • 多组件打包

虽然可以用上述单组件打包的方式为每一个组件打包,但是这样比较麻烦,可以在项目根目录下通过一个配置文件打包所有组件。

此时需要添加额外依赖:

npm i @rollup/plugin-json rollup-plugin-postcss @rollup/plugin-node-resolve cross-env -D
  1. 为组件指定入口文件

在每个包下的package.json文件中添加main和module属性:

"main": "dist/cjs/index.js",
"module": "dist/es/index.js",

  1. 设置环境变量

利用cross-env设置环境变量,区分开发环境和生产环境:

"build:prod": "cross-env NODE_ENV=production rollup -c",
"build:dev": "cross-env NODE_ENV=development rollup -c"

  1. 添加配置文件

在项目的根目录下添加rollup.config.js文件,该文件会遍历packages文件夹下的所有文件夹并打包:

import fs from 'fs'
import path from 'path'
import json from '@rollup/plugin-json'
import vue from 'rollup-plugin-vue'
import { terser } from 'rollup-plugin-terser'
import postcss from 'rollup-plugin-postcss'
import { nodeResolve } from '@rollup/plugin-node-resolve'

const isDev = process.env.NODE_ENV !== 'production'

// 公共插件配置
const plugins = [
    vue({
        css: true,
        compileTemplate: true
    }),
    json(),
    nodeResolve(),
    postcss({
        // 把 css 插入到 style 中
        // inject: true,
        // 把 css 放到和js同一目录
        extract: true
    })
]

// 如果不是开发环境,开启压缩
isDev || plugins.push(terser())
// packages 文件夹路径
const root = path.resolve(__dirname, 'packages')


module.exports = fs.readdirSync(root)
    .filter(item => fs.statSync(path.resolve(root, item)).isDirectory())
    .map(item => {
        // 获取每个包的配置文件
        const pkg = require(path.resolve(root, item, 'package.json'))
        return {
            input: path.resolve(root, item, 'src/index.js'),
            output: [
                {
                    exports: 'auto',
                    file: path.resolve(root, item, pkg.main),
                    format: 'cjs'
                },
                {
                    exports: 'auto',
                    file: path.join(root, item, pkg.module),
                    format: 'es'
                },
            ],
            plugins: plugins
        }
    })

此时执行打包命令,可以一次性为所有组件包打包。

现在有个问题,每次打包的时候需要删除上次打包结果,因此需要添加删除命令:

安装依赖包:

npm i -D rimraf

为每个组件包添加del命令:

"del": "rimraf dist"

在根目录下添加clean命令:

"clean": "yarn workspaces run del"

此时执行yarn clean 就可以清除所有包的dist目录。

plop模版

截止到目前为止,项目的整体结构已经完成,接下来就是无休止的添加组件了,但是考虑到每个组件的初始化有很多相同的工作需要手动完成,此时可以通过plop将这部分工作交给机器。

安装依赖:

npm i plop -D
  1. 创建模版文件

在项目中添加plop-template/component文件夹,此文件夹下放置创建组件用的所有模版文件。

  1. 添加plopfile.js

该文件是plop插件执行的入口文件:

module.exports = plop => {
    plop.setGenerator('component', {
      description: 'create a custom component',
      prompts: [
        {
          type: 'input',
          name: 'name',
          message: 'component name',
          default: 'MyComponent'
        }
      ],
      actions: [
        {
          type: 'add',
          path: 'packages/{{name}}/src/{{name}}.vue',
          templateFile: 'plop-template/component/src/component.hbs'
        }
      ]
    })
}

为plop添加一个可执行的命令,该命令会询问用户组件的名称,然后将模版中所有的文件拷贝到packages相关文件夹内。

  1. 添加scripts命令

"plop": "plop"

此时在命令行中执行yarn plop component就可以创建组件了。

查看原文

赞 3 收藏 2 评论 0

carry 发布了文章 · 9月22日

大前端进阶-如何搭建vue ssr站点

为何需要ssr

  1. 解决单页应用首屏渲染慢的问题。
  2. 解决SEO爬虫抓取时无法获取单页应用完全渲染的页面问题。

完整样例

vue-ssr

基本用法

服务端ssr渲染最基础的功能就是将Vue组件渲染成HTML并返回到浏览器进行展示。

此处需要用到两个依赖包:

npm install vue vue-server-renderer --save

依赖添加完成后,在项目目录下添加index.js文件,该文件作为node启动程序的入口。

const Vue = require('vue')
const { createRenderer } = require('vue-server-renderer')
const app = new Vue({
    template: `<div>Hello World</div>`
})
const renderer = createRenderer()
renderer.renderToString(app, (err, html) => {
    if (err) throw err
    console.log(html)
})

利用renderer提供的renderToString方法可以将vue组件渲染成html字符串,渲染后的结果如下:

image.png

可以看到,渲染完成的html节点添加了data-server-rendered,该属性在后续将用于区分是服务端渲染还是正常的客户端渲染。

完成将vue实例渲染成html字符串之后,还需要搭建一个服务,用于响应用户的请求,并将html字符串返回浏览器用于展示。

此处使用node express

npm install express --save

将上面的渲染代码进行改造,添加服务:

const Vue = require('vue')
const express = require('express')
const { createRenderer } = require('vue-server-renderer')
const { request, response } = require('express')
const app = new Vue({
    template: `<div>Hello World</div>`
})
const renderer = createRenderer()

// 创建服务
const server = express()
server.get('*', async (request, response) => {
    try {
        // 支持中文
        response.setHeader('Content-type',   'text/html;charset=utf-8')
        const html = await renderer.renderToString(app)
        response.end(`
            <!DOCTYPE html>
                <html lang="en">
                <head><title>Hello</title></head>
                <body>${html}</body>
            </html>
        `)
    } catch{
        response.status(500).end('Internal Server Error')
    }
})
// 启动服务 
server.listen(3000)

此时访问http://localhost:3000/就可以正常看到内容被渲染输出到浏览器上了。

使用模版

在基础用例中,我们将vue实例渲染成html字符串,如果需要浏览器能够正常显示,必须用HTML页面包裹这个html字符串。

response.end(`
    <!DOCTYPE html>
    <html lang="en">
        <head><title>Hello</title></head>
        <body>${html}</body>
    </html>
`)

此处明显使用html模版更为方便,在createRenderer创建renderer的时候支持传入模版index.template.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Hello</title>
</head>
<body>
     <!--html字符串注入入口-->
     <!--vue-ssr-outlet-->
</body>
</html>

在模版中必须包含<!--vue-ssr-outlet-->,renderer在生成html字符串之后,会插入到这个地方。

在生成renderer的地方,添加页面模版参数:

const renderer = require('vue-server-renderer').createRenderer({
    template:require('fs').readFileSync('./index.template.html', 'utf-8')
})

模版插值表达式

在html模版中,title, description,meta信息支持用户自定义的,可以在模版中使用插值表达式。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{title}}</title>
    <!--三个大括号表示不需要转译,直接输出-->
    {{{meta}}}
</head>
<body>
    <!--html字符串注入入口-->
    <!--vue-ssr-outlet-->
</body>
</html>

在调用renderToString方法的时候可以传入渲染上下文对象,对象中包含响应插值表达式信息:

const html = await renderer.renderToString(app, {
    title: 'Hello SSR',
    meta: `<meta name="description" content="搭建ssr">`
})

完整结构

在上面的例子中,虽然实现了简单的服务端渲染输出并在浏览器上展示,但是当我们为vue实例添加事件等,会发现页面中相应事件并没有执行成功。

正常的一个ssr系统应该是服务端渲染首评,客户端接管接下来的页面使其成为单页应用。在我们的例子中,通过查看页面中的源码发现服务端返回的html页面并没有加载任何用于客户端接管的js代码,因此出现上面说的注册事件但是没有成功执行。

官网提出的解决方案是区分服务端和客户端入口文件,分别打包,然后利用renderer将打包的结果结合到一起。

image.png

因此我们在项目中添加如下结构的文件:

build
├── webpack.base.config.js
├── webpack.client.config.js
├── webpack.server.config.js
src
├── App.vue
├── app.js # 通用入口
├── entry-client.js # 客户端入口
└── entry-server.js # 服务端入口

App.vue

<template>
    <div id="app">Vue SSR</div>
</template>
<script>
export default {
    name: 'App',
}
</script>
<style>
</style>

app.js

app.js是应用的通用入口,在此文件中导出vue实例。在客户端,将vue实例挂载到Dom上;在服务端,将其渲染为html字符串。

import Vue from 'vue'
import App from './App.vue'
// 导出创建app的工具函数,防止服务端多实例之间相互影响。
export function createApp() {
    const app = new Vue({
        render: h => h(App)
    })
    return { app }
}

entry-client.js

此文件是客户端入口,当客户端执行的时候,vue内部会根据根节点是否包含data-server-rendered属性,如果包含那么就说明页面已经在服务端渲染过,此时客户端只是单纯接管页面。否则就挂载Dom。

import createApp from './app'
// 创建vue实例
const { app } = createApp()
// 挂载
app.$mount('#app')

entry-server.js

此文件服务端入口,在此文件中只是导出一个函数,此函数用于创建vue实例。

import { createApp } from './app'
export default context => {
    const { app } = createApp()
    return app
}

webpack构建

此项目使用webpack进行打包处理,webpack打包需要三个配置文件:

  1. webpack.base.config.js
  2. webpack.client.config.js
  3. webpack.server.config.js

此三个文件详细内容可参考:配置文件

安装依赖包

npm i -D webpack webpack-cli webpack-merge webpack-node-externals @babel/core @babel/plugin-transform-runtime @babel/preset-env babel-loader css-loader url-loader file-loader rimraf vue-loader vue-template-compiler friendly-errors-webpack-plugin

npm i cross-env

添加npm scripts:

"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js",
"build": "rimraf dist && npm run build:client && npm run build:server",

在命令行中执行npm run build,可以看到在dist目录中构建了2个json文件:

image.png

这两个文件用于后续渲染html字符串。

server.js

由于此时项目打包生成了相应的json文件,所以创建renderer和渲染html的方式发生些许变化。

在项目中添加server.js用于服务端node启动文件:

const express = require('express')
const fs = require('fs')
const { createBundleRenderer } = require('vue-server-renderer')
const serverBundle = require('./dist/vue-ssr-server-bundle.json')
const template = fs.readFileSync('./index.template.html', 'utf-8')
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
const renderer = createBundleRenderer(serverBundle, {
    template,
    clientManifest
})
// 创建服务
const server = express()
server.use('/dist', express.static('./dist'))
server.get('/', async (request, response) => {
    try {
        // 支持中文
        response.setHeader('Content-type', 'text/html;charset=utf-8')
        const html = await renderer.renderToString({
            title: 'Hello SSR',
            meta: `<meta name="description" content="搭建ssr">`
})
        response.end(html)
    } catch(e){
        response.status(500).end('Internal Server Error')
    }
})
// 启动服务
server.listen(3000, () => {
    console.log('server running at port 3000.')
})

在命令行中执行 node server.js, 此时能够正常启动站点,并且在浏览器中访问时能够正常展示页面。

开发模式优化

在执行node server.js之后,虽然站点是能够正常访问,但是每次修改之后都需要重新打包,重新启动web服务,这样非常麻烦,也不利于提高开发效率,此时我们希望能够优化开发构建模式,当代码发生变化的时候能够自动构建、自动重启web服务、自动刷新浏览器。

首先在build文件夹下添加setup-dev-server.js文件,该文件用于监控文件变化,当文件发生变化之后,执行用户传入的回调函数:

const fs = require('fs')
const path = require('path')
const chokidar = require('chokidar')
const webpack = require('webpack')
const devMiddleware = require('webpack-dev-middleware')
const hotMiddleware = require('webpack-hot-middleware')
module.exports = function (server, callback) {
    let ready
    const onReady = new Promise(resolve => ready = resolve)
    let serverBundle
    let clientManifest
    let template
    const update = () => {
        if (template && serverBundle && clientManifest) {
            // 构建完成
            ready()
            callback(serverBundle, template, clientManifest)
        }
    }
    // 监听模版文件变化
    const templatePath = path.resolve(__dirname, '../index.template.html')
    template = fs.readFileSync(templatePath, 'utf-8')
    update()
    chokidar.watch(templatePath).on('change', () => {
        template = fs.readFileSync(templatePath, 'utf-8')
        update()
    })
    // 构建监控
    const serverConfig = require('./webpack.server.config')
    const serverCompiler = webpack(serverConfig)
    const serverDevMiddleware = devMiddleware(serverCompiler, {
        logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
    })

    serverCompiler.hooks.done.tap('server', () => {
        serverBundle = JSON.parse(
            // 读取内存中的数据
            serverDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-server-bundle.json'), 'utf-8')
        )
        update()
    })
    const clientConfig = require('./webpack.client.config')
    // 添加HRM plugin
    clientConfig.plugins.push(new webpack.HotModuleReplacementPlugin())
    clientConfig.entry.app = [
        'webpack-hot-middleware/client?quiet=true&reload=true', // 和服务端交互处理热更新一个客户端脚本
        clientConfig.entry.app
    ]
    clientConfig.output.filename = '[name].js' // 热更新模式下确保一致的 hash
    const clientCompiler = webpack(clientConfig)
    const clientDevMiddleware = devMiddleware(clientCompiler, {
        publicPath: clientConfig.output.publicPath,
        logLevel: 'silent' // 关闭日志输出,由 FriendlyErrorsWebpackPlugin 处理
    })
    clientCompiler.hooks.done.tap('client', () => {
        clientManifest = JSON.parse(
            clientDevMiddleware.fileSystem.readFileSync(resolve('../dist/vue-ssr-client-manifest.json'), 'utf-8')
        )
        update()
    })
    server.use(hotMiddleware(clientCompiler, {
        log: false // 关闭它本身的日志输出
    }))
    server.use(clientDevMiddleware)
    return onReady
}

当添加完监控代码后,需要修改server.js,将开发模式和生产模式分离:

const express = require('express')
const fs = require('fs')
const setupDevServer = require('./build/setup-dev-server')
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
// 创建服务
const server = express()
let renderer
// 获取构建监控Promise
let onReady
if (isProd) {
    const serverBundle = require('./dist/vue-ssr-server-bundle.json')
    const template = fs.readFileSync('./index.template.html', 'utf-8')
    const clientManifest = require('./dist/vue-ssr-client-manifest.json')
    renderer = createBundleRenderer(serverBundle, {
        template,
        clientManifest
    })
} else {
    onReady = setupDevServer(server, (serverBundle, template, clientManifest) => {
        renderer = createBundleRenderer(serverBundle, {
            template,
            clientManifest
        })
    })
}
server.use('/dist', express.static('./dist'))
async function render(request, response) {
    try {
        // 支持中文
        response.setHeader('Content-type', 'text/html;charset=utf-8')
        const html = await renderer.renderToString({
            title: 'Hello SSR',
            meta: `<meta name="description" content="搭建ssr">`
        })
        response.end(html)
    } catch (e) {
        response.status(500).end('Internal Server Error')
    }
}
server.get('*', isProd ?
    render :
    async (req, res) => {
    // 开发模式下,需要等待构建完成之后再执行
    await onReady
    render(req, res)
})
// 启动服务
server.listen(3000, () => {
    console.log('server running at port 3000.')
})

路由

Vue项目基本上离不开路由系统,vue ssr支持vue-router,只需要在少许地方作出修改,就能兼容单页应用中的路由写法。

准备工作:

  1. 添加两个路由页面Home, About。
  2. 添加路由注册js
  3. 在App.vue中添加路由出口 <router-view/>

修改app.js,将路由添加到组件实例上:

import Vue from 'vue'
import App from './App.vue'
import { createRouter } from './router/index'
// 导出创建app的工具函数,防止服务端多实例之间相互影响。
export function createApp() {
    const router = createRouter()
    const app = new Vue({
        router,
        render: h => h(App)
    })
    return { app, router }
}

修改entry-server.js, 当首屏渲染的时候,在服务端通过router.push跳转到相应路由页面

import { createApp } from './app'
export default async context => {
    const { app, router } = createApp()
    console.log(context.url)
    router.push(context.url)
    // 当路由完全解析之后再执行渲染
    await new Promise(router.onReady.bind(router))
    return app
}

在server.js中,获取当前请求的url,并添加到渲染上下文中:

const html = await renderer.renderToString({
    url: request.url,
    title: 'Hello SSR',
    meta: `<meta name="description" content="搭建ssr">`
})

页面管理

我们期望在每个路由页面中能够自定义页面title和meta信息,此处可以采用第三方解决方案vue-meta。

在通用入口app.js中添加vue-meta引入

import VueMeta from 'vue-meta'
Vue.use(VueMeta)

在路由页面Home.vue中添加meta信息:

{
    metaInfo: {
        title: '首页'
    }
}

在服务端入口entry-server.js中获取meta信息添加到渲染上下文中:

const meta = app.$meta()
router.push(context.url)
context.meta = meta

修改页面模版index.template.html:

<head>
    {{{ meta.inject().title.text() }}}
    {{{ meta.inject().meta.text() }}}
</head>

数据预取

在服务端渲染过程中,只支持beforeCreate和created声明周期,但是服务端渲染不会等待其内部的异步数据访问,并且获取的数据也不是响应式的,所以通常在生命周期中获取数据并更新页面的方式无法在服务端渲染过程中使用。

服务端给出的解决方案就是在服务端渲染期间获取到的数据存储到Vuex中,然后把容器中的数据同步到客户端。

创建store:

import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'
Vue.use(Vuex)
export const createStore = () => {
    return new Vuex.Store({
        state: {
            posts: [] // 文章列表
        },
        mutations: {
            // 修改容器状态
            setPosts(state, data) {
                state.posts = data
            }
        },
        actions: {
            async getPosts({ commit }) {
                const { data } = await axios({
                    method: 'GET',
                    url: 'https://cnodejs.org/api/v1/topics'
                })
                commit('setPosts', data.data)
            }
        }
    })
}

在app.js中引入store:

export function createApp() {
    const router = createRouter()
    const store = createStore()
    const app = new Vue({
        router,
        store,
        render: h => h(App)
    })
    return { app, router, store }
}

在组件中引入相应state并添加预定义方法serverPrefetch:

computed: {
    ...mapState(['posts'])
},
serverPrefetch () {
    return this.getPosts()
},
methods: {
    ...mapActions(['getPosts'])
}

在服务端入口文件entry-server.js中为渲染上下文添加:

context.rendered = () => {
    // Renderer 会把 context.state 数据对象内联到页面模板中
    // 最终发送给客户端的页面中会包含一段脚本:window.__INITIAL_STATE__ = context.state
    // 客户端就要把页面中的 window.__INITIAL_STATE__ 拿出来填充到客户端 store 容器中
    context.state = store.state
}

客户端入口entry-client.js中将内联的window.__INITIAL_STATE__数据更新到state中:

const { app, router, store } = createApp()
if (window.__INITIAL_STATE__) {
    store.replaceState(window.__INITIAL_STATE__)
}
查看原文

赞 2 收藏 2 评论 0

carry 收藏了文章 · 9月17日

大前端进阶-js性能优化

内存管理

内存由可读写单元组成,表示一片连续可操作的空间。在编程时,可以通过主动操作来申请,使用和释放可操作空间。内存管理指的就是主动操作过程,也就是申请内存,使用内存和释放内存。

// 申请内存
let str
// 使用内存
str = 'foo'
// 释放内存
str = null // 不再引用,垃圾回收会自动回收内存

垃圾回收

当内存不再被使用时,其会被视为垃圾,然后被释放(回收)。

在JavaScript中,垃圾回收是自动进行的。

如何判断垃圾内存?

  1. 对象不再被引用。
  2. 对象不能从根上访问到。
“根”在js中,可以将根看作全局对象。不能从根上访问到指的就是不能从全局对象上通过某条路径找到,可以是直接挂载在全局对象上,也可以是间接挂载在全局对象上。
function fn(obj1, obj2) {
    obj1['next'] = obj2
    obj2['pre'] = obj1
    return {
        o1: obj1,
        o2: obj2
    }
}
const obj = fn()

上述代码的关系如下图所示,此时obj,obj1,obj2都可以从全局对象上找到,因此不能当作垃圾被回收。
微信图片_20200725155844.png
如下图所示,如果通过delete将obj的o1属性和obj2的prev属性删除,那么obj1就无法从全局对象上找到,此时obj1将会被当作垃圾回收。
微信图片_20200725155951.png

可达对象

可到对象指的是能访问到的对象,访问的方式可以是引用,也可以是通过作用域链查找到。
判断一个对象是否是可达对象的标准就是从根出发是否可以被找到。

GC算法

GC可以理解为是垃圾回收机制的简写。算法也就指的是查找垃圾,回收垃圾的规则。
常用的GC算法包含以下几个:

  1. 引用计数
  2. 标记清除
  3. 标记整理
  4. 分代回收

引用计数算法

通过引用计数器设置内存的引用数,当内存的引用关系发生改变的时候修改引用数,当引用数为0的时候内存立即被回收。

// {name: 'zs'}所在的空间是一块内存
// 此时obj1引用这块内存,所以引用计数器上记为1
let obj1= {name: 'zs'}
// obj2 同样引用了这块内存,所以引用计数器为2
let obj2 = obj1
// obj1 不再引用这块内存,所以计数器变为1
obj1 = null
// obj2也不再引用这块内存,此时计数器为0.这块内存会被当作垃圾回收
obj2 = null

算法优点:

  • 发现垃圾时立即回收。
  • 最大程度减少程序暂停(垃圾回收时程序会被暂停,如果回收的速度快,那么暂停的时间也就越少)。

算法缺点:

  • 无法回收循环引用的对象。
function fn() {
    const obj1 = { name: 'zs' }
    const obj2 = { name: 'ls' }
    // 在方法执行完毕以后,obj1和obj2应该被当作垃圾被回收,但是由于其相互引用,此时引用计数器上不为0, 所以无法回收
    obj1['friend'] = obj2
    obj2['friend'] = obj1
}
fn()
  • 时间开销大(由于需要引用计数器,当引用计数器对象越大,每次修改引用数的时间越长)。

标记清除算法

标记清除算法将垃圾回收分为标记和删除阶段,其算法步骤如下:

  1. 遍历所有对象,找到活动对象进行标记。
  2. 遍历所有对象,找到所有没有标记的对象并清除。

如下图所示,第一不找到所有活动对象,由于ABCDE可以通过全局对象找到,所以被标记,a1和b1不能通过全局对象找到,所以不会被标记。第二步,找到没有被标记的a1和b1,将其当作垃圾回收。
微信图片_20200725155851.png
与引用计数算法相比。
优点:

  • 可以回收循环引用的对象

缺点:

  • 回收后内存地址可能不再连续,造成碎片化。

假设内存中有一段连续的内存空间ABCDEF,如果BCDE被标记为活动对象,AB和F没有被标记,那么AB,F会被当作垃圾回收。回收完成后,造成存在AB和F两个碎片内存可以被使用,其只能放入对应长度的数据。

标记整理算法

标记整理算法和标记清除算法类似,只是多了整理内存步骤。

  1. 遍历所有对象,找到活动对象进行标记。
  2. 遍历所有对象,整理标记的内存,然后找到所有没有标记的对象并清除。

通过整理,可以解决标记清除算法造成内存碎片化的问题。

V8引擎

V8是一款主流的JavaScript执行引擎,采用即时编译,内存有限制(64位1.5G,32位800M)。

垃圾回收策略

js中的数据分为原始数据和对象引用数据两种,其中原始数据是由语言本身去处理,所以此处的垃圾回收策略主要针对栈上的对象引用数据。
V8采取分代回收的策略,由于v8对内存大小有限制,所以其将内存分成新生代和老生代两种,不同的生代采取不同的垃圾回收策略。
V8主要采取的GC算法有如下:

  1. 分代回收
  2. 空间复制
  3. 标记清除
  4. 标记整理
  5. 标记增量

新生代

V8将内存分为两块,其中小的空间称为新生代(64位32M/32位16M),其主要存储存活时间较短的对象。新生代内部同样分为两个等大小的空间From和To,通过空间复制和标记整理两个算法完成垃圾回收。

  1. From为使用空间,To为空闲空间,活动对象存储在From。
  2. 标记整理后从From拷贝到To。
  3. 清理From,将From和To交换空间。

From到To的拷贝过程可能出发晋升,也就是从新生代拷贝到老生代,下面两种情况将出发晋升。

  1. 一轮GC之后还存活的新生代。
  2. To空间的使用率超过25%。

老生代

老生代指的是空间较大的内存块(64位1.4G,32位700M),其内部存储存活时间长的对象,采用标记清除,标记整理和增量标记三种算法实现垃圾回收。

  1. 首先采用标记清除进行垃圾回收(会遗留空间碎片)。
  2. 新生代向老生代拷贝并且老生代存储区不足的时候进行空间优化(标记整理)。
  3. 采用增量标记进行效率优化(js代码执行和垃圾回收互斥,执行垃圾回收时无法执行js代码,增量标记指的时将遍历对象进行标记的过程拆分成多个小的执行段,这样js代码执行和标记过程可交叉进行)。

内存问题

js代码在浏览器中执行的时候,可能出现的和内存相关的问题如下:

  • 内存泄露: 内存使用持续增加。
  • 内存膨胀: 内存使用短时间内暴涨,超过内存限制。
  • 分离Dom: Dom节点没有在Dom树上,被变量引用导致无法回收。
  • 频繁GC: GC操作会暂停代码执行,频繁GC会使得页面卡顿。

代码优化

慎用全局变量

全局变量会导致的问题如下:

  • 全局变量存在于全局上下文,全局上下文是作用域链的顶端,当通过作用域链进行变量查找的时候,会延长查找时间。
  • 全局执行上下文会一直存在于上下文执行栈,直到程序推出,这样会影响GC垃圾回收。
  • 如果局部作用域中定义了同名变量,会遮蔽或者污染全局。

缓存全局变量

将不可避免的全局变量缓存到局部作用域中,减少查找时间,优化性能。适用于在局部作用域中频繁使用某个全局变量。

function query() {
    // 在局部作用域中直接使用全局的document变量,在执行时,局部作用域找不到该变量,会沿着作用域链向上查找直到在全局中找到
    return document.getElementsByTagName('input')
}

function query1() {
    // 通过将全局变量赋值给局部变量,那么查找时直接在局部作用域找到,不用再向上查找
    let dom = document
    return dom.getElementsByTagName('input')
}

通过原型新增方法

在为所有的实例对象添加共享方法的时候,通过原型定义比在构造函数中通过this定义性能更好。这是由于构造函数中this定义的方法在每个实例中都会保存一份单独的引用,而通过原型定义,所有的实例会指向同一个引用。

function Person() {
    // 每个实例对象都会保存一份say的引用,10个就会有10个内存引用
    this.say = function () {
        console.log(1)
    }
}
const zs = new Person()

function Person1() { }
// 所有实例的原型都指向一个内存引用,减少内存开销
Person1.prototype.say = function () {
    console.log(1)
}
const ls = new Person1()

避开闭包陷井

闭包是指在外部作用域中可以使用内部作用域中的变量。

function foo() {
    let str = 'foo'
    return function () {
        console.log(str)
    }
}

let f = foo()
// f在外部执行的时候依然能够访问foo作用域中的str变量
f()

闭包是一种常见写法,可以解决js编程中的很多问题,但是由于内部作用域中的变量被外部引用,所以此变量不能被垃圾回收,如果使用不当很容易造成内存泄露,因此在编程中不能为了闭包而闭包

避免属性访问方法使用

js在编写类的时候,很容易的出现在类上提供一个方法,该方法用于访问类内部的一个属性。

function Person() {
    this.name = 'foo'
    // 为了便于控制,在属性的访问上添加了一层
    this.getName = function () {
        return this.name
    }
}
const zs = new Person()
console.log(zs.getName)

function Person1() {
    this.name = 'foo'
}
const ls = new Person1()
// 直接访问属性
console.log(ls.name)

通过jsperf测试,发现直接访问会比包装访问要快的多。因此抛开代码编写规范,单从执行速度上来讲,直接访问更快。

for循环优化

let arr = Array(100).fill('foo')
// 每次循环都要获取数组长度
for (let i = 0; i < arr.length; i++) {
    console.log(i)
}
// 缓存数组长度,
for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

缓存数组长度for循环执行速度要更快,特别适合非常大或者非常复杂的数组遍历。

选择最优的循环方式

let arr = Array(100).fill('foo')
arr.forEach(function (item) {
    console.log(item)
})

for (let i = 0, len = arr.length; i < len; i++) {
    console.log(i)
}

for (let i in arr) {
    console.log(arr[i])
}

通过jsperf工具发现,forEach的执行速度最快,因此在不影响功能的前提下,尽量使用forEach可加快代码的执行速度。

节点添加优化

在平常的js代码编写过程中,常常伴有Dom节点的添加,由于Dom节点添加操作常常伴有回流和重绘,这两个操作比较耗时,可以使用文档碎片优化这种耗时操作。

for (let i = 0; i < 10; i++) {
    let p = document.createElement('p')
    document.body.append(p)
}

let fraEls = document.createDocumentFragment()
for (let i = 0; i < 10; i++) {
    let p = document.createElement('p')
    fraEls.append(p)
}
document.body.append(fraEls)
查看原文

carry 收藏了文章 · 9月16日

大前端进阶-ECMAScript

只要学不死,就往死里学。

概述

ECMAScript是一种可以在宿主环境中执行计算并能操作可计算对象的基于对象的程序设计语言。

ECMAScript是一种语言设计标准,虽然通常也被称为JavaScript,但是严格意义上,ECMAScript并不等于JavaScript,前者是后者的设计标准,后者是前者的具体实现和扩展(在浏览器环境中,JavaScript不仅仅实现了ECMAScript,同时还基于浏览器实现了针对DOM和BOM的操作)。

特性

ECMAScript有很多版本,其中ECMAScript2015最为突出。首先,其改变了传统的以版本命名的方式,改为以年份命名。其次,其明确了标准发布时间,由以前的时间不固定改为一年一个版本。最后,其增加了诸多特性,使js更像现代化编程语言。

ES6可以特指ECMAScript2015, 也可以泛指ECMAScript2015及以后的所有版本。

ECMAScript2015新增的特性可以总结为四大类:

  1. 解决原有语言的不足(let,const,块级作用域等)。
  2. 增强语法(解构,展开,默认参数等)。
  3. 全新的对象,方法,功能(Promise等)。
  4. 全新的数据类型和数据结构(Symbol等)。

块级作用域

在ECMAScript2015以前,js中存在两种作用域:

  1. 全局作用域。
  2. 函数作用域。

ECMAScript2015新增了块级作用域,代码只在相应的代码块中起作用。

// before
if (true) {
    var num = 1
}
console.log(num) // 输出1, 可以在块之外访问变量

// after
if (true) {
    let num = 1
}
console.log(num) // 抱错,不存在num, num只在{}构成的代码块中起作用

let,const

let,const和var一样,都是声明变量的方式,只是let,const会给变量添加作用范围,规避了var带来的诸多问题。

let

  1. let只能先声明,后使用。
// before
console.log(num) // 输出1, 因为浏览器编译js代码的时候,会将变量提升,因此可以在变量声明前访问变量
var num = 1

// after
console.log(num)
let num = 1 // 抱错,因为let变量只能先声明后使用。
  1. let声明的变量只能在块级作用域内起作用。
// before
for (var i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i) // 输出三次 3, setTimeout在执行时,操作的都是全局作用域中的i变量。
    }, 100)
}

// after
for (let i = 0; i < 3; i++) {
    setTimeout(function () {
        console.log(i) // 输出0, 1, 2, 由于每次setTimeout在执行的时候,都使用的是其作用域内的i,不会相互影响
    }, 100)
}

const

const的用法和let相同,只不过,const声明的变量是只读的,不能修改。

这里的只读,指的是不能修改变量指向的内存地址,但是可以修改变量的属性。
const obj = {
    name: 'zhangsan'
}
obj.name = 'lisi' // 可以修改
obj = {
    name: 'lisi'
} // 抱错,修改了变量的内存指向地址,指向了新的地址。

解构

解构允许你使用类似数组或对象字面量的语法将数组和对象的属性赋给各种变量。

// before, 用下面的方式获取数组中的每一项,并赋值给对应的变量
let arr = [1, 2, 3]
let a = arr[0]
let b = arr[0]
let c = arr[0]

// after
let [a, b, c] = arr // 解构会将数组中的每一项自动赋值到对应位置的变量
let [, , c] = arr // 如果只想获取第三个,可以传入两个,
let [a, b] = arr // 如果只想获取前两个,可以省略后面。

// 对象也可以解构
let obj = {
    name: 'zhangsan',
    age: 18,
    gender: true
}
let { name, age, gender } = obj // 由于对象属性没有顺序,因此需要变量和属性名相对应的方式解构。
let { address = '北京' } = obj // 可以为不存在的属性默认值
let { name: otherName = 'lisi'} = obj // 可以为某个属性对应的变量重命名

模版字符串

js原有"", ''两种方式表示字符串,ECMAScript2015新增了``表示字符串。

// 模版字符串支持多行字符串,方便包含换行符的字符串声明
let div = `<div>
              this is div
           </div>`

let name = 'zhangsan'
let intro = `my name is ${name}` // 可以嵌入变量等任何包含返回值的js合法语句,将被替换成返回值。

除了上述基础应用外,还包含一种特殊的带标签的模版字符串。

let obj = {
    name: 'zhangsan',
    age: 18,
    gender: 0
}
// 定义标签方法
// strs表示用${}分割后的字符串数组,后续的参数代表${}对应的计算值
function changeGender(strs, name, gender) {
    console.log(strs, name, gender) // [ '我叫', ',我是', '' ] zhangsan 0

    let g = gender === 0 ? '男人' : '女人'
    return strs[0] + name + strs[1] + g
}

// 使用
console.log(changeGender`我叫${obj.name},我是${obj.gender}`)

字符串扩展

为字符串添加了常见的includes,startsWith,endsWith方法。

const str = 'my name is zhangsan'
console.log(str.includes('name')) // 判断字符串中是否包含name
console.log(str.startsWith('my')) // 判断字符串是否以my开头
console.log(str.endsWith('zhangsan')) // 判断字符串是否以zhangsan结尾

对象字面量增强

加强对象字面量的声明方式,简化声明代码。

let name = 'zhangsan'
let person = {
    // 将同名的变量添加到对象上
    name,
    // 简化对象上方法属性定义
    getName() {
        return this.name
    },
    // []表示计算属性,计算得到的值作为属性的属性名
    [Math.random()]: 18
}

Object.assign

可以将多个对象的属性赋值到目标对象上,有则替换,没有则添加。

let obj = {
    name: 'zhangsan',
    age: 18
}
let obj2 = {
    address: '北京'
}
let person = {
    name: ''
}
console.log(Object.assign(person, obj, obj2))

// 复制一个全新的对象
let copied = Object.assign(person)

Object.is

Object.is可以用于判断值是否相等。

console.log(0 == false) // true, == 会先将值做转换,然后比较
console.log(0 === false) // false, ===会执行严格的判断
// === 无法正确识别的情况
console.log(+0 === -0) // true,
console.log(NaN === NaN) // false , === 认为每个NaN都是独立的一个值
// Object.is可以正确判断上述的两种情况
console.log(Object.is(+0, -0)) // false,
console.log(Object.is(NaN, NaN)) // true 

参数默认值

在函数声明的时候,可以用更简单的方式给参数添加默认值。

// before
function intro(name, age) {
    name = name || 'default'
    console.log(`my name is ${name}`)
}
// after, 默认值参数必须放在非默认值参数的后面
function intro(age, name = 'default') {
    console.log(`my name is ${name}`)
}

剩余参数

对于不定个数参数函数,可以用剩余参数将某个位置以后的参数放入到一个数组中。

// before
function add() {
    // arguments获取所有变量, arguments是一个伪数组
    return Array.from(arguments).reduce(function (pre, cur) {
        return pre + cur
    }, 0)
}
console.log(add(1, 2, 3))

// after
function intro(name, ...args) {
    console.log(name, args) // zhangsan [ 18, '北京' ]
}
intro('zhangsan', 18, '北京')

参数展开

和默认参数相反,参数展开可以在调用函数的时候将数组中的每一项依次赋值给函数中相应位置的参数。

function intro(name, age, address) {
    console.log(name, age, address)
}
const arr = ['zhangsan', 18, '北京']
// before
// 1. 利用获取每个位置的值实现
intro(arr[0], arr[1], arr[2])
// 2. 利用apply方法实现
intro.apply(intro, arr)
// after
intro(...arr)

箭头函数

箭头函数可以简化函数的声明,尤其是在回调函数的声明上。

const arr = ['zhangsan', 18, '北京']
// before
arr.forEach(function (item) {
    console.log(item)
})
// after
arr.forEach(item => console.log(item))

箭头函数与普通的function函数的this指向不同,function的this指向调用者的上下文,是在调用时指定,而箭头函数的this是在声明时指定,指向父级的上下文。

const obj = {
    name: 'lisi',
    getName() {
        console.log(this.name)
    }
}
var name = 'zhangsan'
let getName = obj.getName

getName() // zhangsan, window调用,this指向window
obj.getName() // lisi, obj调用,this指向obj
//----------------------------------------------
const obj2 = {
    name: 'lisi',
    getNameFn() {
        return () => {
            console.log(this.name)
        }
    }
}
obj2.getNameFn()() // lisi, 箭头函数的this指向父级上下文,即getNameFn的上下文,由于getNameFn由obj2调用,因此this指向obj2

Promise

可以利用Promise写出更优雅的异步代码,规避回调地狱。

new Promise(resolve => {
    setTimeout(() => {
        resolve(1)
    }, 100)
}).then(value => {
    setTimeout(() => {
        resolve(value + 1)
    }, 100)
}).then(value => {
    setTimeout(() => {
        resolve(value + 1)
    }, 100)
})

Proxy

通过Proxy代理可以实现对对象编辑获取等操作的拦截,从而在操作之前实现某种操作(例如Vue3.0就是利用Proxy实现数据双向绑定)。其和Object.defineProperty作用类似,但是相比Object.defineProperty,其语法更为简洁,而且作用范围更广(如Object.defineProperty没法监控数组项的增加和删除,Proxy可以)。

对象字面量代理

let obj = {
    name: 'zhangsan',
    age: 18
}
let objProxy = new Proxy(obj, {
    get(target, property) {
        console.log(`获取${property}值`)
        return target[property] ? target[property] : 'default'
    },
    set(target, property, value) {
        if (property === 'age') {
            value = value > 25 ? 25 : value
        }
        target[property] = value
    }
})
console.log(objProxy.address) // default
objProxy.age = 18
console.log(objProxy.age) // 18
objProxy.age = 30
console.log(objProxy.age) // 25

Proxy对象实例化时,第二个参数可以传入更多handler,如下图:
ca08dbb64dc3b1bacbec190628000330.jpg

数组代理

let arr = [1, 2, 3]
let arrProxy = new Proxy(arr, {
    set(target, property, value) {
        value = value > 10 ? 10 : value
        return target[property] = value
    }
})
arrProxy.push(11)
console.log(arr) //[ 1, 2, 3, 10 ], 拦截成功,和push相似的shift,unshift,pop均可触发

Reflect

Reflect是ES2015新增的静态工具类,包含一系列针对对象的操作API,目的是提供统一的对象操作方式,结束目前混杂的对象操作。

let obj = {
    name: 'zhangsan',
    age: 18
}
// before
// get
console.log(obj.name)
console.log(obj['name'])
// set
obj['address'] = '北京'
// delete
delete obj.address
// after
// get
console.log(Reflect.get(obj, 'name'))
// set
Reflect.set(obj, 'address', '北京')
// delete
Reflect.deleteProperty(obj, 'address')

提供统一的操作api不仅代码美观,而且更容易让新手上路。

Reflect提供的方法和Proxy的代理方法是一一对应的,如果Proxy中没有传入相应的代理方法,那么Proxy内部默认使用Reflect对应方法实现。

class

在ES2015之前,js可以使用function和原型链实现类的声明。

function Person(name) {
    // 实例属性
    this.name = name
}
// 实例方法
Person.prototype.intro = function () {
    console.log(`my name is ${this.name}`)
}
// 静态方法
Person.create = function (name) {
    return new Person(name)
}

// 使用
let zhangsan = Person.create('zhangsan')
zhangsan.intro()

ES2015中添加了class关键字,可以用class关键字快速声明类。

class Person {
    // 静态属性
    static tag = 'Person'
    constructor(name) {
        // 实例属性
        this.name = name
    }
    // 实例方法
    intro() {
        console.log(`my name is ${this.name}`)
    }

    // 静态方法, 利用static关键字
    static create(name) {
        return new Person(name)
    }
}
// 使用
let zhangsan = Person.create('zhangsan')
zhangsan.intro()

ES2015在提供快速声明类的class关键字之外,还提供了extends关键字实现类的继承

class Student extends Person {
    constructor(name, number) {
        // 调用父类的构造方法
        super(name)
        // 声明自己的实例属性
        this.number = number
    }

    say() {
        // 调用父类实例方法
        super.intro()
        console.log(`我的学号:${this.number}`)
    }
}

// 使用
let zhangsan = new Student('zhangsan', '10001')
zhangsan.say()

具体关于class的知识点还有很多,不再赘述。

Set

Set是ES2015新增的数据结构,用来表示集合的概念,特点是Set内部的值是不重复的,常常利用这个特点为数组去重。

Set基本使用如下:

// 声明集合,可以传入默认值,不传则是空集合
let s = new Set([1])
// 新增
s.add(2)
// 获取集合长度
console.log(s.size)
// 遍历
s.forEach(item => {
    console.log(item)
})
// 删除
s.delete(2)
// 清空集合
s.clear()

如何去重?

// 简单数据去重
let arr = [1, 2, 1, 'one', 'two', 'one']
console.log(Array.from(new Set(arr)))
// 对象数组去重
let objArr = [
    {
        name: 'zhangsan',
        age: 17
    },
    {
        name: 'lisi',
        age: 16
    },
    {
        name: 'zhangsan',
        age: 17
    }
]
function unique(arr) {
    // Set中判断对象是否重复是判断对象所指内存地址是否相同,所以不能直接将对象数组放入Set中
    // 将对象数组转为字符串数组,方便对比。
    let arrStrs = arr.map(item => JSON.stringify(item))
    let uniqueStrs = Array.from(new Set(arrStrs))
    return uniqueStrs.map(item => JSON.parse(item))
}
console.log(unique(objArr))

Map

Map是ES2015新增的数据结构,用来表示键值对的集合,可以弥补对象字面量的不足。对象字面量只能使用字符串作为键,即使使用计算属性传入非字符串作为键值,对象内部也会将其转为字符串,而Map没有这个限制,其键可以是任何数据。

let obj = new Map()
let person = {
    name: 'zhangsan'
}
// 插入值
obj.set(person, '北京')
// 判断值是否存在
console.log(obj.has(person))
obj.forEach((item, key) => {
    console.log(item, key)
})
// 删除
obj.delete(person)
// 清空
obj.clear()
// 获取大小
console.log(obj.size)

Symbol

Symbol是ES2015引入的新的原始数据类型(和string, number等并列),用来表示独一无二的值(只要调用Symbol(),那么生成的值就不同)。

  • 独一无二
console.log(Symbol() === Symbol())
// Symbol可以接受一个参数作为标记值,这个值只是用于表示Symbol生成的变量的含义(描述自身),方便调试。
// 即使传入相同的参数,那么调用多次返回的值也不相同
console.log(Symbol('bar') === Symbol('bar'))
  • 可用于声明私有属性
let obj = {
    name: 'zhangsan',
    [Symbol()]: '打篮球'
}
console.log(obj[Symbol()]) // undefined , 使用者无法获取obj里面的Symbol键值
console.log(Object.keys(obj)) // [ 'name' ] object的key方法也获取不到
console.log(Object.getOwnPropertySymbols(obj)) // 只有使用getOwnPropertySymbols方法能够获取到对象上定义的所有Symbol类型的键
  • 静态方法

Symbol.for: 方法会根据给定的键 key,来从运行时的 symbol 注册表中找到对应的 symbol,如果找到了,则返回它,否则,新建一个与该键关联的 symbol,并放入全局 symbol 注册表中。

const s = Symbol('bar')
console.log(Symbol.for('bar') === s) // false Symbol()创建的值不会保存在注册表中
console.log(Symbol.for('foo') === Symbol.for('foo')) // true

Symbol.iterator: 可以用作对象的键的值,由于其永远是不重复的,所以不担心被覆盖。在后面的实现Iterable迭代器接口会用到。

for...of

ES2015新增的数据遍历方式,可以用for...of遍历任何数据(只要其实现了Iterable接口)。与forEach相比,其可以在内部添加break关键字随时停止遍历。

const arr = [1, 'one', false, NaN, undefined]
// 遍历数组
for (let item of arr) {
    console.log(item)
    // 终止遍历
    if (!item) {
        break
    }
}
// 遍历Set
const set = new Set(arr)
for (let item of set) {
    console.log(item)
}
// 遍历Map
let map = new Map()
map.set({ name: 'zhangsan' }, '北京')
map.set('age', 18)
for (let [key, value] of map) {
    console.log(key, value)
}

Iterable

上节中提到,如果想要for...of遍历某种数据,那么该数据必须实现Iterable接口。

Iterable接口要求实现一个方法,该方法返回一个迭代器对象(iterator), 迭代器对象包含next方法,next方法返回一个包含value,done两个键值的对象,value保存下一次遍历时的数据,done用于表示迭代器是否完成。
const todos = {
    life: ['吃饭', '睡觉'],
    course: ['语文', '数学', '英语'],
    // 实现Iterable接口
    [Symbol.iterator]: function () {
        let allTodos = [...this.life, ...this.course]
        let index = 0
        return {
            next: function () {
                return {
                    value: allTodos[index],
                    done: index++ >= allTodos.length
                }
            }
        }
    }
}

for(let item of todos) {
    console.log(item)
}

上述代码体现了编程模式中常用的迭代器模式

迭代器模式指的是提供一种方法顺序访问一个聚合对象中的各种元素,而又不暴露该对象的内部表示。

也就是说在todos对象中用了life和course两个字段存储需要做的事情,而外面在使用该对象的时候,不用关心todos对象内部如何存储,只需要通过for...of遍历获取所有数据,这样就降低了数据定义和数据使用的耦合度。当todos新增一个新的字段存储需要做的事情时,只需要修改todos,而不需要需改使用者。

Generator

Generator生成器是ES2015新增的一种异步编程解决方案,用于解决异步编程中回调函数嵌套的问题,其通常使用*和yeild两个关键字。

Generator生成器生成的方法是惰性执行的,只有调用者在调用next方法后其才会执行,遇到yield关键字又停止。

  • 生成id
function* createIdMaker() {
    let id = 0
    while (true) {
        yield id++
    }
}

const idMaker = createIdMaker()
console.log(idMaker.next().value) // 0
console.log(idMaker.next().value) // 1
console.log(idMaker.next().value) // 2
  • Itrerator方法
const todos = {
    life: ['吃饭', '睡觉'],
    course: ['语文', '数学', '英语'],
    // 实现Iterable接口
    [Symbol.iterator]: function* () {
        const all =[...this.life, ...this.course]
        for(let item of all) {
            yield item
        }
    }
}

for (let item of todos) {
    console.log(item)
}
  • 异步编程

详情见上一篇异步编程

Modules

Modules是ES2015提供的标准化模块系统,模块化为你提供了一种更好的方式来组织变量和函数。你可以把相关的变量和函数放在一起组成一个模块。
todo 等js模块化部分学完之后再补充

ES2016

ES2016相对于ES2015是一个小的版本,只提供了如下的小特性。

数组includes方法

提供includes方法方便查找数组中是否存在某一项。

const arr = [1, 'one', false, NaN, undefined]
// before
console.log(arr.indexOf(1) > -1) // true
console.log(arr.indexOf(NaN) > -1) // false 对于NaN的查找出错
// after
console.log(arr.includes(NaN))// true,可以正常查找

指数运算符

方便在大量的数学运算中使用。

// before 
console.log(Math.pow(2, 10))
// after
console.log(2 ** 10)

ES2017

ES2017和ES2016一样,也是小版本。

Object.values

和Object.keys相对应,获取对象的所有值。

const obj = {
    name: 'zhangsan',
    age: 18
}
Object.values(obj).forEach(item => {
    console.log(item)
});

Object.entries

获取键值对的数组,相当于将Object.keys和Object.values组合。

const obj = {
    name: 'zhangsan',
    age: 18
}
Object.entries(obj).forEach(([key, value]) => {
    console.log(key, value)
});

getOwnPropertyDescriptors

用于获取对象的属性描述信息,可以用于补充解决Object.assign的问题。

const obj = {
    firstName: 'zhang',
    lastName: 'san',
    get fullName() {
        return this.firstName + ' ' + this.lastName
    }
}

let copied = Object.assign({}, obj)
copied.firstName = 'li'
console.log(copied.fullName) // zhangsan
//  Object.assign 在拷贝计算属性时,将计算属性的值拷贝过来,导致拷贝后的对象中计算属性有问题

使用getOwnPropertyDescriptors就可以避免这种拷贝问题。

const obj = {
    firstName: 'zhang',
    lastName: 'san',
    get fullName() {
        return this.firstName + ' ' + this.lastName
    }
}

let copied = {}
Object.defineProperties(copied, Object.getOwnPropertyDescriptors(obj))
copied.firstName = 'li'
console.log(copied.fullName)

padStart/padEnd

用于在字符串的前面或者后面填充一定数量的某种字符,可以使字符串显示的更加美观。

const obj = {
    number: '1',
    age: '18'
}

Object.entries(obj).forEach(([key, value]) => {
    console.log(`${key.padEnd(10, '-')}|${value.padStart(3, '0')}`)
})
//number----|001
//age-------|018

允许函数参数中添加尾逗号

允许像数组和对象那样,在声明和调用函数时在末尾加上逗号,只是为了方便部分人的书写习惯,没有实在意义。

const arr = [1, 2, 3,] // 在数组末尾可以加入逗号,不影响数组声明
// 声明函数时可以在参数的末尾加入逗号
function add(a, b,) {
    return a + b
}
// 调用函数时可以在参数的末尾加入逗号
console.log(add(1, 2,))

Async/Await

新增的异步编程解决方案,同样是用于解决回调函数嵌套的问题。详情见上一篇异步编程

ES2018

在对象上应用展开和剩余

和数组的展开和剩余相似,ES2018允许在对象上使用展开和剩余。

const obj = { one: 1, two: 2, three: 3, four: 4, five: 5 }
const { one, four, ...rest } = obj
// one => 1, four => 4
// rest => { two: 2, three: 3, five: 5}

const obj2 = { foo: 'bar', ...rest }
// obj2 => { foo: 'bar', two: 2, three: 3, five: 5}

// 展开时,同名属性会覆盖
const obj3 = { foo: 'bar', two: 200, ...rest }
// obj3 => { foo: 'bar', two: 2, three: 3, five: 5}

const obj4 = { foo: 'bar', ...rest, two: 200 }
// obj4 => { foo: 'bar', two: 200, three: 3, five: 5}

正则:环视

// 环视
const intro = '张三是张三,张三丰是张三丰,张三不是张三丰,张三丰也不是张三'
// 向后否定 正向肯定 只有在张三后面不是丰的时候,才会用李四替代张三
const res1 = intro.replace(/张三(?!丰)/g, '李四')
// 向后肯定 正向肯定 只有在张三后面跟着丰的时候,才会用李四替代张三
const res2 = intro.replace(/张三(?=丰)/g, '李四')
// 向前肯定 反向肯定 只有在00前面是A的时候,才会用88替代00
const res3 = 'A00 B00'.replace(/(?<=A)00/g, '88')
// 向前否定 反向肯定 只有在00前面不是A的时候,才会用88替代00
const res4 = 'A00 B00'.replace(/(?<!A)00/g, '88')

正则:组名称

为正则组添加别名,方便查找正则组

const date = '2020-05-20'
const reg = /(?<year>\d{4})-(?<mouth>\d{2})-(?<day>\d{2})/
const res = reg.exec(date)
console.log(res)
// 可以在groups对象下,通过别名year获取值
console.log(res.groups.year) // 2020

Promise finally

添加finally方法,不论Promise是resolve还是reject,finally都会被执行。

new Promise((resolve, reject) => {
    setTimeout(() => {
      const now = Date.now()
      now * 2 ? resolve(now) : reject(now)
    }, 1000)
  })
  .then(now => {
    console.log('resolved', now)
  })
  .catch(now => {
    console.log('rejected', now)
  })
  .finally(now => {
    console.log('finally', now)
  })

ES2019

数组稳定排序

const arr = [
  { id: 1, value: 'A' },
  { id: 1, value: 'B' },
  { id: 1, value: 'C' },
  { id: 1, value: 'D' },
  { id: 1, value: 'E' },
  { id: 1, value: 'F' },
  { id: 1, value: 'G' },
  { id: 1, value: 'H' },
  { id: 1, value: 'I' },
  { id: 1, value: 'J' },
  { id: 4, value: 'K' },
  { id: 1, value: 'L' },
  { id: 1, value: 'B' },
  { id: 1, value: 'C' },
  { id: 1, value: 'D' },
  { id: 1, value: 'E' },
  { id: 1, value: 'F' },
  { id: 1, value: 'G' },
  { id: 1, value: 'H' },
  { id: 1, value: 'I' },
  { id: 1, value: 'J' },
  { id: 4, value: 'K' },
  { id: 1, value: 'L' },
]

// 旧版本的 ES 排序过后的结果可能不固定
console.log(arr.sort(function (a, b) {
  return a.id - b.id
}))

try...catch可省略参数

简化try...catch语法

try {
    throw new Error()
}
// catch后面可以省略参数e
catch{ }

ES2020

空值合并运算符

function foo(data) {
    // ??表示当data为undefined或者null的时候取100
    let result = data ?? 100
    // 简化下面的写法
    let result1 = data === null || data === undefined ? 100 : data
    console.log(result)
}

可选链运算符

const list = [
    {
        title: 'foo',
        author: {
            name: 'zce',
            email: 'w@zce.me'
        }
    },
    {
        title: 'bar'
    }
]

list.forEach(item => {
    // 如果author属性不存在,那么 item.author?.name 相当于item.author
    console.log(item.author?.name)
})

// 还可以用下面类似的写法
// obj?.prop 获取对象属性值
// obj?.[expr] 获取对象属性值
// arr?.[index] 获取数组指定下标值
// func?.(args) 方法调用
查看原文

carry 收藏了文章 · 9月16日

大前端进阶-函数式编程

最近在学习大前端的进阶,因此想把课程中学到的一些知识点结合这几年的工作所感记录下来。
漂亮的程序千千万,有趣的思想各不同

何为函数式编程

函数式编程是一种思想,可以和面向对象编程和面向过程编程并列。

  1. 面向过程,以过程为中心的编程思想,分析出解决问题所需要的步骤,然后用函数把这些步骤一步一步实现,使用的时候一个一个依次调用。
  2. 面向对象,以对象为中心的编程思想,通俗的讲,就是把现实世界中的某个或者一组事物抽象成为一个对象,通过属性和方法描述其应该具有能力,利用继承,多态等方法描述其变化。(个人觉得,面向过程编程的关键点是如何找到事物的相同点,并按照一定的规则将其设计为对象。)
  3. 函数式编程,顾名思义,以函数为中心的编程思想,但是需要注意的是,此函数非我们常规意义上写代码时写的函数,更趋向数学上的函数,即x => y的推导过程f(x),当f(x)固定后,一定有一个可推导且固定的y值与x值相对应。

函数式编程好处

函数式编程包含以下好处:

  1. 超级方便的代码复用(个人感觉现在公司中的部分前端开发将复制粘贴也当作了代码复用的一种,当和他们提出既然多个页面都用到,为什么不把这个处理逻辑提出来为一个公用的方法呢,得到的回答是粘贴一下就好了,为什么要提出来?额,其实逻辑的使用者不需要关心你内部逻辑是怎么实现的,只需要能保证我输入一组变量,得到我想要的结果就行了)。
  2. 无this(vue2.0对ts支持不是很友好就倒在了这个this上,vue3.0就提出了Composition API解决代码复用和ts支持)。
  3. 方便treeshaking(指的是代码打包过程中,通过分析,可以将无用代码剔除,只保留有用代码,从而减少打包体积,优化加载性能)。
  4. 方便测试(个人感觉在编写前端单元测试用例的时候,如果某段逻辑对外的依赖越强,那么测试用例越不好写,因此在开发的时候通过合理的拆分逻辑能够方便编写测试用例。那么,测试用例是否方便编写是不是衡量逻辑单元是否合理的标志呢?)。

函数式编程特性

函数是一等公民

所谓一等公民指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数,传入另一个函数,或者作为别的函数的返回值。(一等公民 = 啥都可以 ?)

# 变量值
let handler = function () {}
# 参数
let forEach = function (array, handleFn) {}
# 返回值
let handler = function () {
    return function () {

    }
}
# 实例化
let handler = new Function()

高阶函数

高阶函数指的是可以传递一个函数作为参数的函数,通俗的讲,高阶函数也是一个函数,只不过它的参数中有一个是函数。

高阶函数的终极好处是:屏蔽实现细节,关注具体目标。

上文中的forEach就是一个高阶函数,屏蔽实现细节指的是使用者不用关心函数内部是如何对数组进行遍历,如何获取数组中的每个元素。关注具体目标指的是,使用者只关系在获取到数组中的每个元素后需要做什么操作。

闭包

函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript 中,每当函数被创建,就会在函数生成时生成闭包。
  1. 闭包是伴随js函数产生的。
  2. 闭包是在函数作用域内引入非其作用域内的外部状态。

以once函数展示基本的闭包

function once(fn) {
    let done = false
    return function() {
        // 在函数内部作用域内引入外部作用域的done状态,使得done不会随着once的执行完毕被销毁,延长其作用时间
        if(!done) {
            done = true
            fn.apply(fn, arguments)
        }
    }
}

闭包的本质:函数执行完毕之后会被执行栈释放,但是由于其作用域中的状态被外部引用,所以引用的状态不能被释放,还可以被使用。

纯函数

前提: 函数必须有输入输出。
要求: 相同的输入永远会得到相同的输出(输入输出的数据流是显式的),没有可观察的副作用。

副作用是指当调用时,除了返回值之外,还对主调用产生附加的影响。副作用的不仅仅只是返回了一个值,而且还做了其他的事情。通俗的讲就是函数依赖了外部状态或者修改了外部状态。
函数式编程要求函数无副作用,但是副作用是无法完全消除,只能将其控制在可控的范围内。

为什么会有纯函数(纯函数有哪些好处)?

  • 由于输入输出可以相互对应,因此可以针对纯函数的计算结果做缓存。
function momerize(fn) {
    let cache = {}
    return function(...args) {
        let key = JSON.stringify(args)
        cache[key] = cache[key] || fn.apply(fn, args)
        return cache[key]
    }
}
  • 由于纯函数没有副作用,所以方便测试。
  • 由于纯函数没有副作用,所以可以在多线程中调用,可以并行处理。
    js虽然是单线程的,但是最新的标准中添加了WebWork API,支持异步操作。
# 创建者
let worker = new Worker('test.js')
// 向执行者传递数据
worker.postMessage(1)
worker.onmessage = function(evt) {
    // 执行者返回的数据
    console.log(evt.data)
}
# 执行者 test.js
onmessage = function(evt) {
    postMessage(evt.data + 1)
}

## 感觉和electron中主窗口和其他窗口之间通信很相似

柯里化

柯里化(Currying)是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数且返回结果的新函数的技术。

柯里化是对纯函数的封装,将一个多元(包含多个参数)纯函数变为可连续调用的多元或一元函数。也可以理解为,通过柯里化,可以将函数细粒化,达到最大限度的代码重用。

// 简单的柯里化函数
function curry(fn) {
    return function curriedFn(...args) {
        // 如果传入的实参个数和fn的形参个数不一样,那么返回一个函数
        // 调用fn.length可以获取形参个数
        if (args.length < fn.length) {
            return function () {
                return curriedFn(...args.concat(Array.from(arguments)))
            }
        }
        // 如果相同,则调用fn返回结果
        return fn.apply(fn, args)
    }
}

// 多元函数
function sum(a, b, c) {
    return a + b + c
}
// 正常调用
console.log(sum(1, 2, 3))
let curried = curry(sum)
// 柯里化调用
console.log(curried(1)(2)(3))

函数组合

现实编程的过程中,会出现这样的情况,一个数据需要经过多个函数的处理之后才能成为你想要的数据,那么调用时可能会出现类似y = n(g(f(x)))这样的“洋葱“式代码,这种代码既不利于理解,也不利于调试。想要避开这种写法,就可以利用函数组合。函数组合就是将多个细粒化的纯函数组装成一个函数,数据可以在这个组装后的函数中按照一定的顺序执行。

// 简单的组合函数
function compose(...args) {
    return function () {
        // reverse 反转是为了实现从右到左一次执行
        return args.reverse().reduce(function (result, fn) {
            return fn(...result)
        }, arguments)
    }
}
// 下面三个纯函数是为了实现获取数组的最后一项并大写
function reverse(array) {
    return array.reverse()
}

function first(array) {
    return array[0]
}

function toUpper(str) {
    return str.toUpperCase()
}

const arr = ['a', 'b', 'c']

// 原始调用
console.log(toUpper(first(reverse(arr))))

const composed = compose(toUpper, first, reverse)
// 组合后调用
console.log(composed(arr))

从上例中可以看出,如果想要函数组合,那么有个必要前提:被组合的函数必须是有输入输出的纯函数。

函子

函子是函数式编程中最重要的数据类型,也是基本的运算单位和功能单位。

函子是两个范畴之间的一种映射(关系)

什么是范畴?
范畴是一个数学概念,通俗的讲,当某组事物之间存在某种关系,通过这种关系可以将事物组中的某个事物转变为另一个事物,那么这组事物和他们之间的关系就可以构成一个范畴。两个范畴之间可以相互转换,函子就是描述范畴之间如何转换(通过函子可以将一个范畴转换为另一个范畴)。

函数式编程中最基本的一个函子如下:函子可以看作是一个盒子,盒子中保存一个数据,调用者可通过map方法操作盒子中的数据。

class Functor {
    // 通过提供静态的of方法,可以使调用者避开new(new更趋向面向对象)
    static of(value) {
        return new Functor(value)
    }
    // 存储外部传递的数据,将数据封闭,不对外开放
    constructor(value) {
        this._value = value
    }

    // 外部通过map方法传递如何处理存储的数据,并将结果变为一个新的函子(可以实现链式操作)
    map(fn) {
        return Functor.of(fn(this._value))
    }
}

Maybe函子

函子可以接受任意函数,用于处理内部的数据,但是当函子内部数据为null的时候,map处理时会抱错。
Functor.of(null).map(x => x.toUpperCase())
Maybe函子就是为了解决这种问题,在其map处理数据的时候会判断数据是否为空。

class Maybe extends Functor {

    isEmpty() {
        return this._value === null || this._value === undefined
    }

    map(fn) {
        return this.isEmpty() ? Maybe.of(null) : Maybe.of(fn(this._value))
    }
}

Either函子

Either函子用来描述if...else...,因此它内部需要两个值right和left,右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

Either常见的使用场景有两个:

  1. 添加默认值
  2. 替代try...catch
class Either {
    static of(left, right) {
        return new Either(left, right)
    }
    constructor(left, right) {
        this._left = left
        this._right = right
    }
    isEmpty() {
        return this._right === null || this._right === undefined
    }

    map(fn) {
        return this.isEmpty() ? Either.of(fn(this._left), this._right) : Either.of(this._left, fn(this._right))
    }
}
const user = {}
// 提供默认值
Either.of({ name: 'zs' }, user.name).map(
    u => {
        console.log(u.name)
        return u.name
    }
)
// 替代try...catch
function toUpper(str) {
    try {
        Either.of(null, str.toUpperCase())
    } catch (e) {
        Either.of(e, null)
    }
}

IO函子

在纯函数一节中提到过函数的副作用,我们应该将副作用控制在一定范围之内,IO函子就是为了解决这一问题,通过将有副作用的数据包装起来,让调用方决定如何使用这部分数据。

class IO {
    // value 是指有副作用的数据
    static of(value) {
        return new IO(function () {
            return value
        })
    }
    constructor(fn) {
        // fn方法用于包装有副作用的数据,在调用者真正使用数据的时候返回数据
        this._value = fn
    }

    map(fn) {
        // 用到了组合函数compose,将fn和value组成一个新的函数
        return new IO(compose(fn, this._value))
    }
}

// 调用: 获取node的执行路径
const io = IO.of(process).map(x => x.execPath)
console.log(io._value())

Monad函子

函子是一个盒子,内部包含了一个数据,函子也可以看作一个数据,那么就会出现函子内部的数据也是一个函子,即出现了函子的层层嵌套。Monad函子就是为了解决此问题,通过join和flatMap方法解嵌套。

上文中的IO函子上面加上join,flatMap方法,其也可以称为Monad函子。

class IO {
    // value 是指有副作用的数据
    static of(value) {
        return new IO(function () {
            return value
        })
    }
    constructor(fn) {
        // fn方法用于包装有副作用的数据,在调用者真正使用数据的时候返回数据
        this._value = fn
    }

    map(fn) {
        // 用到了组合函数compose,将fn和value组成一个新的函数
        return new IO(compose(fn, this._value))
    }

    join() {
        return this._value()
    }

    flatMap(fn) {
        return this.map(fn).join()
    }
}

const fs = require('fs')
let readFile = function (filename) {
    return new IO(function () {
        return fs.readFileSync(filename, 'utf-8')
    })
}
let print = function (x) {
    return new IO(function () {
        console.log(x)
        return x
    })
}

let result = readFile('test.html')
    // 将读取到的内容转为大写
    .map(x => x.toUpperCase())
    // 由于print函数返回一个函子,那么flatMap可以揭开第一层嵌套,返回print返回的函子
    .flatMap(print)
    // 获取函子的结果
    .join()
console.log(result)
查看原文

carry 发布了文章 · 9月3日

大前端进阶-同构应用

概述

前端领域中,随着Vue,React,Angular等框架的流行,前端工程化、模块化成为了当下主流技术方案。这类框架构建的SPA单页应用具有用户体验好、渲染性能好、可维护性高等有点,但是也存在以下两个方面的问题:

  1. 首屏加载时间长

SPA应用采用客户端渲染,用户需要等待客户端js解析完成之后才能看到页面,这样会导致首屏加载时间变长,用户体验差。

  1. 不利于SEO

由于SPA应用采用客户端渲染,在js未完成解析之前,网站HTML是没有内容的,这样导致搜索引擎爬取站点HTML时获取不到内容。

为了解决这两个问题,业界提出了一种新的解决方案,如下图:
image.png

利用服务端渲染解决首屏加载慢和不利于SEO的缺陷,首屏渲染完成之后,客户端渲染接管页面重新成为单页应用以保证良好的用户体验。这种方式称为现代化服务端渲染方式或者同构渲染。

与传统服务端渲染区别

传统服务端渲染,如JSP可以总结为以下几步:

  1. 客户端发送请求
  2. 服务端根据请求查找模板并获取数据。
  3. 执行渲染
  4. 生成html返回客户端展示

这种传统服务端渲染方式,会存在以下缺点:

  1. 前后端完全耦合,不利于开发维护。
  2. 前端发挥空间小。
  3. 服务端压力大。
  4. 用户体验一般。

而同构渲染只是在首屏渲染的时候和传统服务端类似,都是返回渲染好的html页面,但是,同构渲染中,当客户端展示渲染好的html页面后,客户端渲染会接管页面的控制权,也就是后续的渲染都是由客户端进行的,这样可以保证良好的用户体验。

同构渲染缺点

与单页应用相比,由于首屏渲染采用的是服务端渲染,所以存在以下缺点:

  1. 开发条件有限,开发有限制(服务端只能使用nodejs,而且并不是所有的工具包都能在服务端渲染中使用)。
  2. 涉及构建和部署的要求高(需要部署客户端和服务端,不能再和单页应用一样,部署静态站点即可)。
  3. 更多的服务端负载。

Nuxt.js

Nuxt.js是基于vue技术栈的一种同构应用解决方案,它屏蔽了vuejs构建同构应用的难点。

基本使用

搭建nuxtjs同构应用相对比较简单,通过以下四步即可搭建最简单的同构应用:

  1. 创建项目文件夹。
  2. npm或者yarn安装nuxt。
  3. 添加pages文件夹并在其中添加index.vue文件。
  4. 执行npx nuxt。

基础路由

pages文件夹下存放所有的路由页面vue文件,nuxt会根据pages文件夹自动生成路由。

如pages目录如下:

pages/
--| user/
-----| index.vue
-----| one.vue
--| index.vue

那么nuxt会自动生成如下的路由结构:

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'user',
      path: '/user',
      component: 'pages/user/index.vue'
    },
    {
      name: 'user-one',
      path: '/user/one',
      component: 'pages/user/one.vue'
    }
  ]
}

动态路由

nuxt在解析路由结构时,如果遇到以_为前缀的vue文件时,会将其定义为带参数的动态路由。

如下面的文件结构:

pages/
--| _slug/
-----| comments.vue
-----| index.vue
--| users/
-----| _id.vue
--| index.vue

生成的路由表为:

router: {
  routes: [
    {
      name: 'index',
      path: '/',
      component: 'pages/index.vue'
    },
    {
      name: 'users-id',
      path: '/users/:id?',
      component: 'pages/users/_id.vue'
    },
    {
      name: 'slug',
      path: '/:slug',
      component: 'pages/_slug/index.vue'
    },
    {
      name: 'slug-comments',
      path: '/:slug/comments',
      component: 'pages/_slug/comments.vue'
    }
  ]
}

嵌套路由

在vuejs中,我们可以通过子路由实现路由的多层嵌套,同样在nuxtjs中,可以通过特殊的文件结构实现嵌套路由。

创建嵌套路由,需要添加一个Vue文件,同时添加一个与该文件名称相同的文件夹用于存放子路由组件(在Vue文件中需要添加类似route-view的nuxt-child节点。)

如下面的文件结构:

pages/
--| users/
-----| _id.vue
-----| index.vue
--| users.vue

生成的路由表如下:

router: {
  routes: [
    {
      path: '/users',
      component: 'pages/users.vue',
      children: [
        {
          path: '',
          component: 'pages/users/index.vue',
          name: 'users'
        },
        {
          path: ':id',
          component: 'pages/users/_id.vue',
          name: 'users-id'
        }
      ]
    }
  ]
}

自定义路由

nuxtjs不仅可以通过pages文件夹结构自动生成路由表,同样提供了配置文件的方式配置自定义路由。

nuxtjs的配置文件和vue.config.js一样,在项目根目录下创建nuxt.config.js文件,此文件默认导出一个配置对象,配置对象有一个router属性,可以在此属性下配置自定义路由。

export default {
    router: {
        extendRoutes(routes, resolve) {
            // 可以采用此方式清空默认生成的路由
            // routes.splice(0)
            routes.push(...[
                {
                    path: '/',
                    component: resolve(__dirname, 'your.vue'),
                }
            ])
        }
    }
}

模板

nuxtjs是基于html模板生成html文件的,我们可以在项目中修改此模板。

定制模板需要在项目根目录下创建app.html文件,默认的html结构如下:

<!DOCTYPE html>
<html {{ HTML_ATTRS }}>
  <head {{ HEAD_ATTRS }}>
    {{ HEAD }}
  </head>
  <body {{ BODY_ATTRS }}>
    {{ APP }}
  </body>
</html>

我们可以在head中添加相应自定义的css、js引用。

asyncData

在vuejs项目中,某个组件如果想要从服务端获取数据,一般都会在created声明周期函数中发起请求。在同构应用中,如果想要页面内容能够SEO,那么就不能采用此方式。

在nuxt中为vue组件添加了asyncData方法,此方法会在服务端被执行,执行后的结果会被合并到当前组件的data对象中。

如下例,在服务端获取所有的文章和标签并渲染:

async asyncData() {
    const [articleRes, tagRes] = await Promise.all([
      getAllArticles({
        offest: 0,
        limit: 10,
      }),
      getAllTags(),
    ])

    const { articles, articlesCount } = articleRes.data
    const { tags } = tagRes.data
    return {
      articles,
      articlesCount,
      tags,
    }
  }

需要注意的是,此方法虽然是vue组件的一个方法,但是由于其在服务端被调用,所以方法内部不能通过this对象获取vue实例,如果想要获取实例上的某些信息,可以用上下文对象。

上下文对象

asyncData方法包含一个参数context,context对象上包含路由等常用信息。

route: 当前路由对象
store: store对象
params, query: 路由参数
req, res: 请求,响应对象
redirect: 用于重定向

身份验证

在同构应用中,当用户登录之后,其身份信息需要在客户端和服务端都能够被获取到,因此可以将信息保存在cookie中。

客户端获取信息并存入cookie:

methods: {
    onSubmit() {
        let request = this.isLogin ? login : register
        request(this.user)
            .then(({ data }) => {
                // 获取到用户信息
                const { user } = data
                if (user) {
                    const Cookie = process.client ? require('js-cookie') : undefined
                    // 将获取到的用户信息保存在cookie中
                    Cookie.set('user', user)
                    this.$store.commit('setUser', user)
                }
            })
            .catch((err) => {

            })
    }
}

服务端读取cookie并存入store供客户端使用:

在store的action中可以添加一个名称为nuxtServerInit的异步方法,该方法会在nuxt应用启动的时候被调用。

nuxtServerInit({ commit }, { req }) {
    let user = null
    if (req && req.headers.cookie) {
        const cookieParser = process.server ? require('cookieparser') : undefined;
        const parsed = cookieParser.parse(req.headers.cookie)
        try {
            user = JSON.parse(parsed.user)
        } catch (err) {
        }
    }
    commit('setUser', user)
}

插件

如果想要在vuejs程序运行之前执行某些js操作,如注册第三方组件库,此时可以利用插件。

所有的插件全部放在plugins目录下,如果想要在nuxt应用中使用elementui组件库,可以在plugins文件夹下添加element-ui.js文件:

import Vue from 'vue'
import Element from 'element-ui'
import locale from 'element-ui/lib/locale/lang/en'

Vue.use(Element, { locale })

然后在nuxt.config.js的plugins属性数组中添加相应文件路径:

plugins: [
    '@/plugins/element-ui',
]

之后,就可以在vue组件中正常使用element-ui。

查看原文

赞 0 收藏 0 评论 0

carry 收藏了文章 · 9月2日

大前端进阶-读懂vuejs源码2

前文中,已经分析了在vuejs源码中是如何定义Vue类,以及如何添加实例属性和静态方法:大数据进阶-读懂vuejs源码1

Vue实例化时调用_init,本文将深入该方法内部做了哪些事情及vuejs如何实现数据响应式。

Vue初始化

core/instance/index.js文件中定义了Vue的构造函数:

function Vue (options) {
  // 执行_init方法,此方法在initMixin中定义
  this._init(options)
}

_init方法定义在core/instance/init.js中:

Vue.prototype._init = function (options?: Object) {
    // 。。。
    // 1. 合并options
    if (options && options._isComponent) {
        // 此处有重要的事情做。
        initInternalComponent(vm, options)
    } else {
        vm.$options = mergeOptions(
            resolveConstructorOptions(vm.constructor),
            options || {},
            vm
        )
    }
    // 2. 初始化属性
    // 初始化$root,$parent,$children
    initLifecycle(vm)
    // 初始化_events
    initEvents(vm)
    // 初始化$slots/$scopedSlots/_c/$createElement/$attrs/$listeners
    initRender(vm)
    // 执行生命周期钩子
    callHook(vm, 'beforeCreate')
    // 注册inject成员到vue实例上
    initInjections(vm) // resolve injections before data/props
    // 初始化_props/methods/_data/computed/watch
    initState(vm)
    // 初始化_provided
    initProvide(vm) // resolve provide after data/props
    // 执行生命周期钩子
    callHook(vm, 'created')

    // 3. 调用$mount方法
    if (vm.$options.el) {
        vm.$mount(vm.$options.el)
    }
}

在合并options的时候,如果options表示一个组件(_isComponent)则调用了initInternalComponent函数:

export function initInternalComponent(vm: Component, options: InternalComponentOptions) {
    // 此处保留组件之间的父子关系,
    const parentVnode = options._parentVnode
    opts.parent = options.parent
    opts._parentVnode = parentVnode
    //...
}

此方法中设置了组件之间的父子关系,在后续的注册及渲染组件的时候会用到。

initProvide

定义在core/instance/inject.js文件中。

export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

在上面的代码中可以看出,如果provide是一个函数,那么会调用这个函数,并将this指向vm实例。由于initProvide在_init方法中最后被调用,因此能够访问到实例的属性。

initInjections

定义在core/instance/inject.js文件中。

export function initInjections (vm: Component) {
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    // 遍历result属性,利用Object.defineProperty将其添加到vue实例上
    // ...
  }
}

此方法调用resolveInject方法获取所有inject值。

export function resolveInject(inject: any, vm: Component): ?Object {
    if (inject) {
        const result = Object.create(null)
        const keys = hasSymbol
            ? Reflect.ownKeys(inject)
            : Object.keys(inject)

        for (let i = 0; i < keys.length; i++) {
            // ....
            const provideKey = inject[key].from
            let source = vm
            while (source) {
                if (source._provided && hasOwn(source._provided, provideKey)) {
                    result[key] = source._provided[provideKey]
                    break
                }
                source = source.$parent
            }
            // ...
        }
        return result
    }
}

在resolveInject方法中会从当前实例出发,延着parent一直向上找,直到找到_provided中存在。

总结

此时整个Vue定义和初始化流程可以总结为如下:

image.png

数据响应式

vuejs框架的整个数据响应式实现过程比较复杂,代码散落在各个文件中。我们都知道,在定义组件的时候,组件会自动将data属性中的数据添加上响应式监听,因此我们从_init方法中调用initState函数开始。

启动监听

在initState函数中:

export function initState (vm: Component) {
  // ...
  if (opts.data) {
    // 处理data数据
    initData(vm)
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  // ...
}

options中的data数据会交由initData方法处理:

function initData(vm: Component) {
    // ... 1. 获取data数据,如果data是一个函数,但没有返回值,会提示错误。
    // ... 2. 遍历data所有的属性,首先判断在props和methods是否同名,然后将其代理到vue实例上。
    // 3. 添加响应式数据监听
    observe(data, true /* asRootData */)
}

添加监听

observe函数

定义在core/observer/index.js文件中:

export function observe (value: any, asRootData: ?boolean): Observer | void {
  if (!isObject(value) || value instanceof VNode) {
    return
  }
  let ob: Observer | void
  // 通过__ob__属性判断该属性是否添加过响应式监听
  if (hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {
    // 如果添加过,不做处理,直接返回
    ob = value.__ob__
  } else if (
    shouldObserve &&
    !isServerRendering() &&
    (Array.isArray(value) || isPlainObject(value)) &&
    Object.isExtensible(value) &&
    !value._isVue
  ) {
    // 创建Observer实例,其为响应式的核心
    ob = new Observer(value)
  }
  // 通过vmCount可以判断某个响应式数据是否是根数据,可以理解为data属性返回的对象是根数据,如果data对象的某个属性也是一个对象,那么就不再是根数据。
  // vmCount属性后续会被用到
  if (asRootData && ob) {
    ob.vmCount++
  }
  return ob
}

该方法的核心就是为data数据创建Observer实例ob, ob对象会为data添加getter/setter方法,其可以用来收集依赖并在变化的时候触发dom更新。

Observer类

定义在core/observer/index.js文件中,在其构造函数中,根据传入data的类型(Array/Object),分别进行处理。

Object
constructor(value: any) {
    this.value = value
    // Observer实例上包含dep属性,这个属性后续会有很大作用,有些无法监听的数据变化可以由此属性完成
    this.dep = new Dep()
    this.vmCount = 0
    // 为data添加__ob__属性
    def(value, '__ob__', this)
    if (Array.isArray(value)) {
        // ... 处理数组
    } else {
        // 处理对象
        this.walk(value)
    }
}
  • walk

遍历data的所有属性,调用defineReactive函数添加getter/setter。

walk(obj: Object) {
    const keys = Object.keys(obj)
    for (let i = 0; i < keys.length; i++) {
        // 添加数据拦截
        defineReactive(obj, keys[i])
    }
}
  • defineReactive

数据响应式实现的核心方法,原理是通过Object.defineProperty为data添加getter/setter拦截,在拦截中实现依赖收集和触发更新。

export function defineReactive(
    obj: Object,
    key: string,
    val: any,
    customSetter?: ?Function,
    shallow?: boolean
) {
    // 1. 创建闭包作用域内的Dep对象,用于收集观察者,当数据发生变化的时候,会触发观察者进行update
    const dep = new Dep()
    const property = Object.getOwnPropertyDescriptor(obj, key)
    if (property && property.configurable === false) {
        return
    }
    // 2. 获取对象描述中原有的get和set方法
    const getter = property && property.get
    const setter = property && property.set
    if ((!getter || setter) && arguments.length === 2) {
        val = obj[key]
    }
    let childOb = !shallow && observe(val)
    // 3. 添加getter/setter
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function reactiveGetter() {
            const value = getter ? getter.call(obj) : val
            // 静态属性target存储的是当前观察者。
            if (Dep.target) {
                dep.depend()
                if (childOb) {
                    // 将观察者添加到Obsetver实例属性dep中。
                    childOb.dep.depend()
                    if (Array.isArray(value)) {
                        dependArray(value)
                    }
                }
            }
            return value
        },
        set: function reactiveSetter(newVal) {
            const value = getter ? getter.call(obj) : val
            /* eslint-disable no-self-compare */
            if (newVal === value || (newVal !== newVal && value !== value)) {
                return
            }
            // ... 一些判断,省略
            // 当赋值的时候,如果值为对象,需要为新赋值的对象添加响应式
            childOb = !shallow && observe(newVal)
            // 调用set就是为属性赋值,赋值说明有新的变化,所以要触发更新
            dep.notify()
        }
    })
}

整个defineReactive有两个地方比较难以理解:

  1. 通过Dep.target获取依赖

由于这个地方涉及到后面的编译部分,所以我们把这部分逻辑单独拿出来,用一段简短的代码来描述整个过程,如下:

// 模拟Dep
let Dep = {}
Dep.target = null

// 模拟变化数据
let data = {
    foo: 'foo'
}

Object.defineProperty(data, 'foo', {
    get() {
        if (Dep.target) {
            console.log(Dep.target)
        }
    }
})

// 模拟编译 {{foo}}
// 1. 解析到template中需要foo属性的值
const key = 'foo'
// 2. 在foo属性对应的值渲染到页面之前,为Dep.target赋值
Dep.target = () => {
    console.log('观察foo的变化')
}
// 3. 获取foo属性的值,此时会触发get拦截
const value = data[key]
// 4. 获取完成后,需要将Dep.target的值重新赋值null,这样下一轮解析的时候,能够存储新的观察者
Dep.target = null
  1. 在闭包作用域内已经包含了Dep对象,在set中通过此对象的notify方法触发更新,为什么还需要在get方法中,将依赖添加到Observer对象的实例属性dep中。

其实,这是为了方便在其他手动触发更新,由于defineReactive方法内部的dep对象是闭包作用域,在外部无法直接访问,只能通过赋值方式触发。

如果在Observer对象上保存一份,那么就可以通过data.__ob__.dep的方式访问到,直接手动调用notify方法就可以触发更新,在Vue.set方法内部实现就可以这种触发更新方式。

Array

众所周知,Object.defineProperty是无法监控到通过push,pop等方法改变数组,此时,vuejs通过另外一种方式实现了数组响应式。该方式修改了数组原生的push,pop等方法,在新定义的方法中,通过调用数组对象的__ob__属性的notify方法,手动触发更新。

Observer构造函数中:

if (Array.isArray(value)) {
    if (hasProto) {
        // 支持__proto__,那么就通过obj.__proto__的方式修改原型
        protoAugment(value, arrayMethods)
    } else {
        // 不支持,就将新定义的方法遍历添加到数组对象上,这样可以覆盖原型链上的原生方法
        copyAugment(value, arrayMethods, arrayKeys)
    }
    // 遍历数组项,如果某项是对象,那么为该对象添加响应式
    this.observeArray(value)
}

其中arrayMethods就是重新定义的数组操作方法。

  • arrayMethods

定义在core/Observer/array.js文件中,该文件主要作了两件事情:

  1. 创建新的集成自Array.prototype的原型对象arrayMethods。
const arrayProto = Array.prototype
export const arrayMethods = Object.create(arrayProto)
  1. 在新的原型对象上,添加自定义方法覆盖原生方法。
// 定义所有会触发更新的方法
const methodsToPatch = [
    'push',
    'pop',
    'shift',
    'unshift',
    'splice',
    'sort',
    'reverse'
]


methodsToPatch.forEach(function (method) {
    // 获取Array中原生的同名方法
    const original = arrayProto[method]
    // 通过Object.defineProperty为方法调用添加拦截
    def(arrayMethods, method, function mutator(...args) {
        // 调用原生方法获取本该得到的结果
        const result = original.apply(this, args)
        const ob = this.__ob__
        let inserted
        // push,unshift,splice三个方法会向数组中插入新值,此处根据情况获取新插入的值
        switch (method) {
            case 'push':
            case 'unshift':
                inserted = args
                break
            case 'splice':
                inserted = args.slice(2)
                break
        }
        // 如果新插入的值是对象,那么需要为对象添加响应式,处理逻辑和data处理逻辑相似
        if (inserted) ob.observeArray(inserted)
        // 手动触发更新
        ob.dep.notify()
        return result
    })
})

从上面的处理逻辑可以看出,下面的数组操作可以触发自动更新:

// 修改数组项
[].push(1)
[].pop()
[].unshift(1)
[].shift()
[].splice()
// 修改数组项顺序
[].sort()
[].reverse()

而下面的操作不能触发:

// 修改数组项
[1, 2][0] = 3
[1, 2].length = 0

Dep

在添加数据监听的过程中用到了Dep类,Dep类相当于观察者模式中的目标,用于存储所有的观察者和发生变化时调用观察者的update方进行更新。

export default class Dep {
    // 当前需要添加的观察者
    static target: ?Watcher;
    // id,唯一标识
    id: number;
    // 存储所有的观察者
    subs: Array<Watcher>;

    constructor() {
        this.id = uid++
        this.subs = []
    }

    // 添加观察者
    addSub(sub: Watcher) {
        this.subs.push(sub)
    }

    // 移除观察者
    removeSub(sub: Watcher) {
        remove(this.subs, sub)
    }

    // 调用观察者的addDep方法,将目标添加到每一个观察者中,观察者会调用addSub方法
    depend() {
        if (Dep.target) {
            Dep.target.addDep(this)
        }
    }

    // 将观察者排序,然后依次调用update
    notify() {
        // stabilize the subscriber list first
        const subs = this.subs.slice()
        if (process.env.NODE_ENV !== 'production' && !config.async) {
            // subs aren't sorted in scheduler if not running async
            // we need to sort them now to make sure they fire in correct
            // order
            subs.sort((a, b) => a.id - b.id)
        }
        for (let i = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}

Watcher

Watcher类是观察者模式中的观察者,当Dep触发变化的时候,会调用内部存储的所有Watcher实例的update方法进行更新操作。

在vuejs中,Watcher可大致分为三种:Computed Watcher, 用户Watcher(侦听器)和渲染Watcher(触发Dom更新)。

Watcher类包含大量的实例成员,在构造函数中,主要逻辑如下:

constructor(
    vm: Component,
    expOrFn: string | Function,
    cb: Function,
    options ?: ? Object,
    isRenderWatcher ?: boolean
) {
    // ... 根据参数为实例成员赋值
    // 调用get方法
    this.value = this.lazy
        ? undefined
        : this.get()
}

在get方法中,获取初始值并将自身添加到Dep.target。

get() {
    // 1. 和下面的popTarget相对应,这里主要是为Dep.target赋值
    // 由于存在组件之间的父子关系,所以在pushTarget中还会将当前对象存放到队列中,方便处理完成子组件后继续处理父组件
    pushTarget(this)
    let value
    const vm = this.vm
    try {
        // 2. 获取初始值,并触发get监听,Dep会收集该Watcher
        value = this.getter.call(vm, vm)
    } catch (e) {
        if (this.user) {
            handleError(e, vm, `getter for watcher "${this.expression}"`)
        } else {
            throw e
        }
    } finally {
        // 实现deep深度监听
        if (this.deep) {
            traverse(value)
        }
        // 3. 将Dep.target值变为null
        popTarget()
        this.cleanupDeps()
    }
    return value
}
addDep

addDep方法用于将当前Watcher实例添加到Dep中。

addDep(dep: Dep) {
    const id = dep.id
    // 确保不会重复添加
    if (!this.newDepIds.has(id)) {
        this.newDepIds.add(id)
        this.newDeps.push(dep)
        if (!this.depIds.has(id)) {
            // 调用dep的addSub方法,将Watcher实例添加到Dep中
            dep.addSub(this)
        }
    }
}
update

update主要处理两种情况:

  1. 如果是用户添加的监听器,在变化的时候会执行run方法。
  2. 如果是渲染Dom时添加的,在变化的时候会执行queueWatcher函数,在queueWatcher函数中,通过队列的方式批量执行更新。
update() {
    if (this.lazy) {
        this.dirty = true
    } else if (this.sync) {
        // 用户添加的监听器会执行run方法
        this.run()
    } else {
        // 触发dom更新会执行此方法, 以队列方式执行update更新
        queueWatcher(this)
    }
}
run

run方法主要用于在数据变化后,执行用户传入的回调函数。

run() {
    if (this.active) {
        // 1. 通过get方法获取变化后的值
        const value = this.get()
        if (
            value !== this.value ||
            isObject(value) ||
            this.deep
        ) {
            // 2. 获取初始化时保存的值作为旧值
            const oldValue = this.value
            this.value = value
            if (this.user) {
                try {
                    // 3. 调用用户定义的回调函数
                    this.cb.call(this.vm, value, oldValue)
                } catch (e) {
                    handleError(e, this.vm, `callback for watcher "${this.expression}"`)
                }
            } else {
                this.cb.call(this.vm, value, oldValue)
            }
        }
    }
}

生成渲染Watcher

在查找编译入口那部分讲到了platforms/web/runtime/index.js文件定义了$mount方法,此方法用于首次渲染Dom。

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

其内部执行了mountComponent函数。

mountComponent

定义在core/instance/lifecycle.js文件中,该函数主要执行三块内容:

  1. 触发beforeMount,beforeUpdatemounted生命周期钩子函数。
  2. 定义updateComponent方法。
  3. 生成Watcher实例,传入updateComponent方法,此方法会在首次渲染和数据变化的时候被调用。
export function mountComponent(
    vm: Component,
    el: ?Element,
    hydrating?: boolean
): Component {
    // ... 1. 触发生命周期钩子
    // 2. 定义updateComponent方法
    let updateComponent
    if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        updateComponent = () => {
            // ...
            vm._update(vnode, hydrating)
            // ...
        }
    } else {
        updateComponent = () => {
            vm._update(vm._render(), hydrating)
        }
    }

    // 生成watcher实例
    new Watcher(vm, updateComponent, noop, {
        before() {
            if (vm._isMounted && !vm._isDestroyed) {
                callHook(vm, 'beforeUpdate')
            }
        }
    }, true /* isRenderWatcher */)
    hydrating = false

    // ... 触发生命周期钩子
    return vm
}

_update, _render

_update, _render是Vue的实例方法, _render方法用于根据用户定义的render或者模板生成的render生成虚拟Dom。_update方法根据传入的虚拟Dom,执行patch,进行Dom对比更新。

总结

至此,响应式处理的整个闭环脉络已经摸清。

image.png

查看原文

carry 收藏了文章 · 9月2日

大前端进阶-读懂vuejs源码3

在前面两节中,分别说明了vuejs中如何声明Vue类以及vue数据响应式如何实现:

  1. Vue声明过程
  2. Vue数据响应式实现过程

本节将探讨虚拟Dom及模版解析过程。

虚拟Dom

vuejs中的虚拟Dom实现基于snabbdom,在其基础上添加了组件等插件,关于snabbdom如何创建虚拟Dom及patch对比更新过程可以参考Snabbdom实现原理

_render

在Vue实例执行$mount方法挂载Dom的时候,在其内部执行了mountComponent函数。

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

在mountComponent函数内部创建了Watcher对象(参考Vue数据响应式实现过程),当首次渲染和数据变化的时候会执行updateComponent函数。

updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }

该函数内部调用了Vue的实例方法_render和_update。其中_render方法的作用是生成虚拟Dom。

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    // 访问slot等占位节点
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
        currentRenderingInstance = vm
        // 调用传入的render方法
        vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
        // 处理错误
    } finally {
        currentRenderingInstance = null
    }
    // ... 特殊情况判断
    // 设置父子关系
    vnode.parent = _parentVnode
    return vnode
}

其实,通过代码可以看出,这个方法的主要作用是调用$options中的render方法,该方法来源有两个:

  1. 用户自定义render。
  2. vue根据模版生成的render。

_renderProxy

通过call改变了render的this指向,让其指向vm._renderProxy, _renderProxy实例定义在core/instance/init.js的initMixin中:

if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

通过代码可以看出,vm._renderProxy指向的就是vm。

$createElement

vm.$createElement是render方法的第一个参数,也就是我们开发过程中常用的h函数,其定义在core/instance/render.jsinitRender函数中:

export function initRender(vm: Component) {
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}

其调用的就是/vdom/create-element.js文件中的createElement函数,由于vdom文件夹下存放的都是虚拟Dom有关的操作。

createElement

createElement用于生成Vnodes:

export function createElement(
    context: Component,
    tag: any,
    data: any,
    children: any,
    normalizationType: any,
    alwaysNormalize: boolean
): VNode | Array<VNode> {
    // 处理参数,针对不同参数个数进行初始化处理
    return _createElement(context, tag, data, children, normalizationType)
}

其内部调用_createElement函数进行具体逻辑操作:

export function _createElement(
    context: Component,
    tag?: string | Class<Component> | Function | Object,
    data?: VNodeData,
    children?: any,
    normalizationType?: number
): VNode | Array<VNode> {
    // 1. 判断传入参数是否符合要求,如果不合要求应该怎样处理
    // ... 省略
    if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children)
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children)
    }

    // 2. 创建vnode
    let vnode, ns
    if (typeof tag === 'string') {
        let Ctor
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
        if (config.isReservedTag(tag)) {
            if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
                warn(
                    `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
                    context
                )
            }
            vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
            )
        } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
            // component
            vnode = createComponent(Ctor, data, context, children, tag)
        } else {
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            )
        }
    } else {
        vnode = createComponent(tag, data, context, children)
    }

    // 3. 对生成的vnode进行判断,如果不合要求,进行处理
    // ...
}

创建Vnodes有以下几种情况:

  1. tag是字符串而且是Dom中的元素,直接生成普通元素的Vnode。
  2. tag是字符串,但是属于组件($options.components),调用createComponent生成Vnode。
  3. tag是一个对象,那么默认该对象代表一个组件,调用createComponent生成Vnode。
  • createComponent

定义在core/instance/vdom/create-component.js文件中:

export function createComponent(
    Ctor: Class<Component> | Function | Object | void,
    data: ?VNodeData,
    context: Component,
    children: ?Array<VNode>,
    tag?: string
): VNode | Array<VNode> | void {
    
    // 1. 使用Vue.extend将组件选项生成一个继承自Vue的组件类
    const baseCtor = context.$options._base
    if (isObject(Ctor)) {
        Ctor = baseCtor.extend(Ctor)
    }

    // 2. 处理组件中的特殊定义

    // 3. 合并patch过程中使用到的生命周期hook
    installComponentHooks(data)

    // 4. 根据前面生成的数据,调用new VNode生成虚拟Dom
    const name = Ctor.options.name || tag
    const vnode = new VNode(
        `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
        data, undefined, undefined, undefined, context,
        { Ctor, propsData, listeners, tag, children },
        asyncFactory
    )
    return vnode
}

其中第三步合并生命周期hook函数在组件渲染挂载过程会被用到,这个在后续的Vue.component定义组件时继续讨论。

  • new VNode

VNode是一个类,包含一些描述该节点信息的实例成员,生成Vnode就是将一组数据组合成一个VNode实例。

_update

_update方法的作用是将虚拟Dom渲染到页面视图上:

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevVnode = vm._vnode
    if (!prevVnode) {
        // 首次渲染
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
        // 数据更新
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
}

不论是首次渲染还是数据更新,其调用的都是__patch__方法。

__patch__过程会操作Dom,因此属于web平台上的特有操作,因此其定义在platforms/web/runtime/index.js中:

Vue.prototype.__patch__ = inBrowser ? patch : noop

其实现调用了platforms/web/runtime/patch.js中导出的patch函数。

patch

patch过程和snabbdom的patch过程非常相近,只是针对vuejs特殊语法做了一些修改,此处不再详细说明,可以参考Snabbdom实现原理

总结

虚拟Dom的整个渲染过程可以总结为以下几步:

  1. vue调用$mount挂载Dom。
  2. 判断创建Vue实例时是否传入render方法,如果没传,那么将根据模版生成render函数。
  3. 创建Watcher,传入updateComponent函数。
  4. Watcher实例化时,会判断此Watcher是否是渲染Watcher,如果是,则调用updateComponent。
  5. updateComponent函数中会调用_render方法生成虚拟Dom,调用_update方法根据传入的虚拟Dom渲染真实Dom。
  6. 如果数据发生变化,会通过__ob__属性指向的Dep通知第四步中创建的Watcher,Watcher内部会再次调用updateComponent执行更新渲染。

模版编译

只有在完整版的vuejs中才包含模版编译部分的代码,如果是通过vue-cli创建的项目,将没有此部分功能。

模版编译的过程包含如下几步:

  1. 解析Dom的ast语法树。
  2. 根据ast生成render字符串。
  3. 将render字符串转换为render函数。

编译入口

platforms/web/entry-runtime-with-compiler.js文件中,会判断$mount时是否传入了render函数,如果没有传入,会根据模版编译render函数。

Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean
): Component {
    // ...
    // 编译template为render函数
    const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
    }, this)
    // ...
}

其中,compileToFunctions返回的render函数就是最终生成虚拟Dom用的render函数,staticRenderFns为静态树优化,用于优化patch功能。

  • compileToFunctions

定义在platforms/web/compiler/index.js中:

const { compile, compileToFunctions } = createCompiler(baseOptions)

是由高阶函数createCompiler函数执行返回的。

  • createCompiler

定义在compilter/index.js文件中:

export const createCompiler = createCompilerCreator(function baseCompile(
    template: string,
    options: CompilerOptions
): CompiledResult {
    // 1. 生成ast
    const ast = parse(template.trim(), options)
    // 2. 针对ast进行优化
    if (options.optimize !== false) {
        optimize(ast, options)
    }
    // 生成render函数
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
})

是调用createCompilerCreator生成的,在调用时传入了编译核心函数baseCompile,该函数中将编译细化为三步:

  1. 生成ast。
  2. 优化ast
  3. 生成render函数
  • createCompilerCreator

定义在compiler/reate-compiler.js文件中,

export function createCompilerCreator(baseCompile: Function): Function {
    return function createCompiler(baseOptions: CompilerOptions) {
        // 编译函数:补充options,调用baseCompiler编译
        function compile(
            template: string,
            options?: CompilerOptions
        ): CompiledResult {
            // 1. 合并用户options和默认options,创建错误和提示数组对象。
            // 2. 调用baseCompile执行具体编译工作
            const compiled = baseCompile(template.trim(), finalOptions)
            // 3. 将编译过程中的错误和提示添加到编译结果上。
            return compiled
        }

        return {
            compile,
            compileToFunctions: createCompileToFunctionFn(compile)
        }
    }
}

其内部扩展了编译函数,添加了默认配置和错误收集,然后调用createCompileToFunctionFn生成最终的编译函数。

  • createCompileToFunctionFn

定义在compiler/to-function.js中:

export function createCompileToFunctionFn(compile: Function): Function {
    // 1. 添加编译结果缓存
    const cache = Object.create(null)
    return function compileToFunctions(
        template: string,
        options?: CompilerOptions,
        vm?: Component
    ): CompiledFunctionResult {
        // 2. 根据编译得到的render字符串调用new Function生成编译函数
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors)
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
            return createFunction(code, fnGenErrors)
        })

        return (cache[key] = res)
    }
}

此方法继续扩展编译方法,提供了缓存和将render字符串转换成render函数功能。

ast语法

生成ast语法树:

const ast = parse(template.trim(), options)

其本质是调用parse函数将html字符串转换为普通js对象描述的树结构数据,内部调用的是http://erik.eae.net/simplehtmlparser/simplehtmlparser.js这个工具库,有兴趣的可以自己看一下。

优化ast

优化ast主要是找到并标记静态根节点,一旦标记静态根节点,那么就会带来两个好处:

  1. 把它们变成常数,这样我们就不需要了在每次重新渲染时为它们创建新的节点。
  2. 在patch过程中能够跳过这些静态根节点。

那么,什么是静态根节点呢?

静态根节点是指永远不会发生变化的Dom树,在Vuejs中,如果满足下面三个条件,就认为是静态根节点:

  1. 必须存在子节点。
  2. 如果子节点只有一个,该子节点不能是文本节点。
  3. 所有子节点都是静态节点(当数据发生变化的时候,节点不会发生改变)。
if (options.optimize !== false) {
    optimize(ast, options)
}

在编译的时候调用optimize函数执行具体的优化操作。

  • optimize

定义在compiler/optimize.js文件中:

export function optimize(root: ?ASTElement, options: CompilerOptions) {
    // ...
    // 1. 找到并标记所有的静态节点
    markStatic(root)
    // 2. 找到并标记所有静态根节点
    markStaticRoots(root, false)
}
  • markStatic
function markStatic (node: ASTNode) {
  // 1. 直接判断node节点是不是静态节点
  node.static = isStatic(node)
  if (node.type === 1) {
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    // 2. 遍历子节点, 如果子节点其中一个为非静态节点,那么修改本节点为非静态节点。
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        node.static = false
      }
    }
    // 3. if节点的处理和第2步相同
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

如何判断是否是静态节点?

简单来讲,如果数据变化的时候,该节点会发生变化,那么此节点就不是静态节点,感兴趣的可自行查看isStatic内部实现。

  • markStaticRoots

用于查找并标记所有的静态根节点,判断依据可以参考前面提到的如何断定一个节点是静态根节点。

function markStaticRoots(node: ASTNode, isInFor: boolean) {
    if (node.type === 1) {
        // 节点被标记为静态节点,说明所有子节点都为静态节点
        if (node.static || node.once) {
            node.staticInFor = isInFor
        }
        // 包含至少一个子节点,如果包含一个子节点,此节点不是文本节点
        if (node.static && node.children.length && !(
            node.children.length === 1 &&
            node.children[0].type === 3
        )) {
            node.staticRoot = true
            return
        } else {
            node.staticRoot = false
        }
        // 遍历所有子节点进行标记
        if (node.children) {
            for (let i = 0, l = node.children.length; i < l; i++) {
                markStaticRoots(node.children[i], isInFor || !!node.for)
            }
        }
        // 遍历所有if节点标记
        if (node.ifConditions) {
            for (let i = 1, l = node.ifConditions.length; i < l; i++) {
                markStaticRoots(node.ifConditions[i].block, isInFor)
            }
        }
    }
}

那么优化的静态根节点在实际过程中如何使用呢?

以下面的模版为例:

<div id="app">
    <span>
      <strong>文本</strong>
    </span>
    <span>{{msg}}</span>
  </div>

编译成render函数内部如下:

with (this) {
    return _c('div',
        { attrs: { "id": "app" } },
        [
            _m(0),
            _v(" "),
            _c('span', [_v(_s(msg))])
        ])
}

在render函数中_m(0)返回的虚拟Dom代表的就是静态根节点:

<span>
    <strong>文本</strong>
</span>

静态根节点的Vnode结果缓存在staticRenderFns中,_m函数就是根据元素索引去获取缓存的结果,这样每次调用_render生成虚拟Dom的时候就可以使用缓存,避免重复渲染。

生成render函数

首先生成render字符串

const code = generate(ast, options)

generate内部根据ast生成render函数字符串:

export function generate(
    ast: ASTElement | void,
    options: CompilerOptions
): CodegenResult {
    const state = new CodegenState(options)
    const code = ast ? genElement(ast, state) : '_c("div")'
    return {
        render: `with(this){return ${code}}`,
        staticRenderFns: state.staticRenderFns
    }
}

然后在createCompileToFunctionFn函数中调用createFunction函数将render函数字符串转换为render函数:

function createFunction(code, errors) {
    try {
        return new Function(code)
    } catch (err) {
        errors.push({ err, code })
        return noop
    }
}

至此,整个渲染过程大功告成。

下一节会探讨vuejs一些常用实例方法的实现方式。

查看原文

carry 发布了文章 · 8月26日

大前端进阶-读懂vuejs源码4

在前面文章中,详细探讨了Vue声明流程,Vuejs响应式实现流程,虚拟Dom及模版编译流程,感兴趣的童鞋可以自己查看。

  1. Vue声明过程
  2. Vuejs响应式实现流程
  3. 虚拟Dom及模版编译

本篇文章将继续探讨Vuejs中一些常用方法的实现过程,包含$set,component,extend。

  • 为什么探讨$set方法?

在Vue中可以通过this.$set()为一个响应式数据添加新的属性或者响应式数组添加新项,内部是如何实现的?

$delete内部实现和$set内部实现相似,探讨一个,可以举一反三。

  • 为什么探讨component方法?

component方法用于注册组件,探讨此方法,可以弄清楚vuejs内部是如何注册及渲染组件的。

$set

在官方文档中,实例方法$set是Vue静态方法set的一个别名,二者实现原理一样。

使用set方法可以在响应式数据中添加新的属性或者新项:

<body>
  <div id="app">
    <span>
      <strong>{{person.name}}</strong>
    </span>
    <span>{{person.age}}</span>
  </div>
  <script>
    let vm = new Vue({
      el: '#app',
      data() {
        return {
          person: {
            name: 'zs'
          }
        }
      }
    })
    vm.$set(vm.$data.person, 'age', 12)
  </script>
</body>

$set方法定义在core/instance/state.js文件中:

export function stateMixin(Vue: Class<Component>) {
    Vue.prototype.$set = set
    Vue.prototype.$delete = del
}

其调用的是set函数,该函数定义在core/observer/index.js中:

  1. 判断目标target是否是一个响应式对象,如果目标没有定义或者是一个非响应式对象,那么在测试环境下就会发出警告:
if (process.env.NODE_ENV !== 'production' &&
    (isUndef(target) || isPrimitive(target))
) {
    warn(`Cannot set reactive property on undefined, null, or primitive value: ${(target: any)}`)
}
  1. 如果目标target是一个数组,那么首先判断key是否是一个有效的索引数字,然后判断target数组能否包含key传入的索引,如果不能包含,则调用length修改数组长度,然后再调用splice修改数组插入值。
if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val)
    return val
}

在前面响应式原理中已经说过,通过splice修改数组,能够触发响应。

  1. 如果新增的属性已经存在,那么说明此属性已经添加了响应式,直接返回结果即可。
if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
}
  1. 获取target上存储的ob对象,如果存在ob对象,那么就通过Object.defineProperty为新添加的属性添加getter/setter。然后调用ob.dep.notify方法触发更新。
const ob = (target: any).__ob__
// target._isVue代表target是Vue实例
// ob && ob.vmCount 代表target指向的是$data
if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
        'Avoid adding reactive properties to a Vue instance or its root $data ' +
        'at runtime - declare it upfront in the data option.'
    )
    return val
}
if (!ob) {
    target[key] = val
    return val
}
defineReactive(ob.value, key, val)
ob.dep.notify()
return val

extend

Vue.extend是使用一个包含组件选项的对象创建一个继承自Vue的子类。

官网示例如下:

<div id="mount-point"></div>
// 创建构造器
var Profile = Vue.extend({
  template: '<p>{{firstName}} {{lastName}} aka {{alias}}</p>',
  data: function () {
    return {
      firstName: 'Walter',
      lastName: 'White',
      alias: 'Heisenberg'
    }
  }
})
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#mount-point')

这样做的好处是:

在通常vue-cli项目中,我们可以通过路由将不同的Dom挂载到id为app的div中,但是类似alert等,应该添加在body节点上,此时就可以用Vue.extend定义一个Alert类,然后在合适的时机渲染并挂载到body中:

const alertComponent = new Alert().$mount()
document.body.appendChild(alertComponent.$el)

extend方法定义在core/global-api/extend.js中:

  1. 创建原型链继承,核心内容是将新生成的子类的prototype指向一个原型为Vue的对象:Sub.prototype = Object.create(Super.prototype)
extendOptions = extendOptions || {}
const Super = this
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
if (cachedCtors[SuperId]) {
    return cachedCtors[SuperId]
}

const name = extendOptions.name || Super.options.name
if (process.env.NODE_ENV !== 'production' && name) {
    validateComponentName(name)
}

const Sub = function VueComponent(options) {
    this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
Sub.cid = cid++
Sub.options = mergeOptions(
    Super.options,
    extendOptions
)
Sub['super'] = Super
  1. 添加实例成员和静态成员:
if (Sub.options.props) {
    initProps(Sub)
}
if (Sub.options.computed) {
    initComputed(Sub)
}

Sub.extend = Super.extend
Sub.mixin = Super.mixin
Sub.use = Super.use

ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
})

if (name) {
    Sub.options.components[name] = Sub
}

Sub.superOptions = Super.options
Sub.extendOptions = extendOptions
Sub.sealedOptions = extend({}, Sub.options)

cachedCtors[SuperId] = Sub

component

Vue.component方法用于注册全局组件,本部分将探索全局组件如何实例化及渲染。

Vue.component方法定义在core/global-api/assets.js文件中:

ASSET_TYPES.forEach(type => {
    Vue[type] = function (
        id: string,
        definition: Function | Object
    ): Function | Object | void {
        if (!definition) {
            return this.options[type + 's'][id]
        } else {
            if (process.env.NODE_ENV !== 'production' && type === 'component') {
                validateComponentName(id)
            }
            if (type === 'component' && isPlainObject(definition)) {
                definition.name = definition.name || id
                // 调用extend方法生成一个Vue的子类
                definition = this.options._base.extend(definition)
            }
            if (type === 'directive' && typeof definition === 'function') {
                definition = { bind: definition, update: definition }
            }
            this.options[type + 's'][id] = definition
            return definition
        }
    }
})

在注册时,会调用extend方法生成一个子类并添加到全局的Vue.options.components对象上。

组件Vnode创建过程

下面是一个使用组件的示例:

const Comp = Vue.component('comp', {
    template: '<div>Hello Component</div>'
})

const vm = new Vue(
    {
        el: '#app',
        render(h) {
            return h(Comp)
        }
    }
)

在render函数中通过h(Comp)的方式创建组件Vnode,在虚拟Dom一章中我们说过h参数其实就是createElement函数,该函数定义在core/vdeom/create-element.js中,内部调用了_createElement函数:

export function _createElement(
    context: Component,
    tag?: string | Class<Component> | Function | Object,
    data?: VNodeData,
    children?: any,
    normalizationType?: number
): VNode | Array<VNode> {
    // ...
    if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
        // 调用createComponent函数生成组件Vnode
        vnode = createComponent(Ctor, data, context, children, tag)
    }
    // ...
}

在此函数中,如果Ctor是一个组件,那么就调用createComponent生成Vnode。

export function createComponent (
    Ctor: Class<Component> | Function | Object | void,
    data: ?VNodeData,
    context: Component,
    children: ?Array<VNode>,
    tag?: string
  ): VNode | Array<VNode> | void {
    // ...
    installComponentHooks(data)
    const name = Ctor.options.name || tag
    const vnode = new VNode(
      `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
      data, undefined, undefined, undefined, context,
      { Ctor, propsData, listeners, tag, children },
      asyncFactory
    )
    // ...
  
    return vnode
  }

在createComponent方法中会调用installComponentHooks函数合并componentVNodeHooks 中预定义的钩子函数和用户传入的钩子函数。

const componentVNodeHooks = {
    init(vnode: VNodeWithData, hydrating: boolean): ?boolean {
        if (
            vnode.componentInstance &&
            !vnode.componentInstance._isDestroyed &&
            vnode.data.keepAlive
        ) {
            const mountedNode: any = vnode
            componentVNodeHooks.prepatch(mountedNode, mountedNode)
        } else {
            // 创建组件实例并添加到vnode.componentInstance属性上。
            const child = vnode.componentInstance = createComponentInstanceForVnode(
                vnode,
                activeInstance
            )
            // 执行$mount方法,将组件挂载到页面
            child.$mount(hydrating ? vnode.elm : undefined, hydrating)
        }
    },

    prepatch(oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
        //...
    },

    insert(vnode: MountedComponentVNode) {
        //...
    },

    destroy(vnode: MountedComponentVNode) {
        // ...
    }
}

在预定义的init钩子函数中,会创建组件实例并调用$mount方法将组件挂在到页面。

export function createComponentInstanceForVnode(
    vnode: any, // we know it's MountedComponentVNode but flow doesn't
    parent: any, // activeInstance in lifecycle state
): Component {
    const options: InternalComponentOptions = {
        _isComponent: true,
        _parentVnode: vnode,
        parent
    }
    const inlineTemplate = vnode.data.inlineTemplate
    if (isDef(inlineTemplate)) {
        options.render = inlineTemplate.render
        options.staticRenderFns = inlineTemplate.staticRenderFns
    }
    // 创建组件实例
    return new vnode.componentOptions.Ctor(options)
}

那么init钩子函数什么时候被执行?

通过虚拟Dom的工作机制可以看出,当页面首次渲染和数据变化的时候会执行patch函数,在patch函数内部会调用createComponent函数,此函数定义在core/vdom/patch.js中:

function createComponent(vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
        const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
        // 1. 调用init钩子函数,组件在创建vnode的时候已经添加了此钩子函数
        if (isDef(i = i.hook) && isDef(i = i.init)) {
            i(vnode, false /* hydrating */)
        }

        // 2. 判断vnode是否定义了componentInstance属性,此属性在init钩子函数中用于存放组件实例
        if (isDef(vnode.componentInstance)) {
             // 调用其他钩子函数,用于设置局部作用域样式等
            initComponent(vnode, insertedVnodeQueue)
            // 把组件dom插入到父元素中
            insert(parentElm, vnode.elm, refElm)
            if (isTrue(isReactivated)) {
                reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
            }
            return true
        }
    }
}

在此函数中完成了init钩子函数调用及挂载dom。

此时组件从注册到渲染完成的整个流程已经梳理完毕,总结以下,可以分为以下几个步骤:

  1. Vue.component注册组件。
  2. 在声明Vue组件的render函数时,用h生成组件Vnode
  3. 在h函数内部会调用createComponent创建组件Vnode,此过程中会添加内置init钩子函数。
  4. 首次渲染或者数据变更时会调用patch函数,此函数内部会调用里一个createComponent函数。
  5. createComponent函数会调用init钩子函数生成组件实例。
  6. init钩子函数内部会创建组件实例并调用$mount函数渲染。
  7. createComponent会将渲染后的dom添加到父元素中。
查看原文

赞 0 收藏 0 评论 0

carry 发布了文章 · 8月26日

大前端进阶-读懂vuejs源码3

在前面两节中,分别说明了vuejs中如何声明Vue类以及vue数据响应式如何实现:

  1. Vue声明过程
  2. Vue数据响应式实现过程

本节将探讨虚拟Dom及模版解析过程。

虚拟Dom

vuejs中的虚拟Dom实现基于snabbdom,在其基础上添加了组件等插件,关于snabbdom如何创建虚拟Dom及patch对比更新过程可以参考Snabbdom实现原理

_render

在Vue实例执行$mount方法挂载Dom的时候,在其内部执行了mountComponent函数。

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

在mountComponent函数内部创建了Watcher对象(参考Vue数据响应式实现过程),当首次渲染和数据变化的时候会执行updateComponent函数。

updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }

该函数内部调用了Vue的实例方法_render和_update。其中_render方法的作用是生成虚拟Dom。

Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options
    // 访问slot等占位节点
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
        currentRenderingInstance = vm
        // 调用传入的render方法
        vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
        // 处理错误
    } finally {
        currentRenderingInstance = null
    }
    // ... 特殊情况判断
    // 设置父子关系
    vnode.parent = _parentVnode
    return vnode
}

其实,通过代码可以看出,这个方法的主要作用是调用$options中的render方法,该方法来源有两个:

  1. 用户自定义render。
  2. vue根据模版生成的render。

_renderProxy

通过call改变了render的this指向,让其指向vm._renderProxy, _renderProxy实例定义在core/instance/init.js的initMixin中:

if (process.env.NODE_ENV !== 'production') {
      initProxy(vm)
    } else {
      vm._renderProxy = vm
    }

通过代码可以看出,vm._renderProxy指向的就是vm。

$createElement

vm.$createElement是render方法的第一个参数,也就是我们开发过程中常用的h函数,其定义在core/instance/render.jsinitRender函数中:

export function initRender(vm: Component) {
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
}

其调用的就是/vdom/create-element.js文件中的createElement函数,由于vdom文件夹下存放的都是虚拟Dom有关的操作。

createElement

createElement用于生成Vnodes:

export function createElement(
    context: Component,
    tag: any,
    data: any,
    children: any,
    normalizationType: any,
    alwaysNormalize: boolean
): VNode | Array<VNode> {
    // 处理参数,针对不同参数个数进行初始化处理
    return _createElement(context, tag, data, children, normalizationType)
}

其内部调用_createElement函数进行具体逻辑操作:

export function _createElement(
    context: Component,
    tag?: string | Class<Component> | Function | Object,
    data?: VNodeData,
    children?: any,
    normalizationType?: number
): VNode | Array<VNode> {
    // 1. 判断传入参数是否符合要求,如果不合要求应该怎样处理
    // ... 省略
    if (normalizationType === ALWAYS_NORMALIZE) {
        children = normalizeChildren(children)
    } else if (normalizationType === SIMPLE_NORMALIZE) {
        children = simpleNormalizeChildren(children)
    }

    // 2. 创建vnode
    let vnode, ns
    if (typeof tag === 'string') {
        let Ctor
        ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
        if (config.isReservedTag(tag)) {
            if (process.env.NODE_ENV !== 'production' && isDef(data) && isDef(data.nativeOn)) {
                warn(
                    `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
                    context
                )
            }
            vnode = new VNode(
                config.parsePlatformTagName(tag), data, children,
                undefined, undefined, context
            )
        } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
            // component
            vnode = createComponent(Ctor, data, context, children, tag)
        } else {
            vnode = new VNode(
                tag, data, children,
                undefined, undefined, context
            )
        }
    } else {
        vnode = createComponent(tag, data, context, children)
    }

    // 3. 对生成的vnode进行判断,如果不合要求,进行处理
    // ...
}

创建Vnodes有以下几种情况:

  1. tag是字符串而且是Dom中的元素,直接生成普通元素的Vnode。
  2. tag是字符串,但是属于组件($options.components),调用createComponent生成Vnode。
  3. tag是一个对象,那么默认该对象代表一个组件,调用createComponent生成Vnode。
  • createComponent

定义在core/instance/vdom/create-component.js文件中:

export function createComponent(
    Ctor: Class<Component> | Function | Object | void,
    data: ?VNodeData,
    context: Component,
    children: ?Array<VNode>,
    tag?: string
): VNode | Array<VNode> | void {
    
    // 1. 使用Vue.extend将组件选项生成一个继承自Vue的组件类
    const baseCtor = context.$options._base
    if (isObject(Ctor)) {
        Ctor = baseCtor.extend(Ctor)
    }

    // 2. 处理组件中的特殊定义

    // 3. 合并patch过程中使用到的生命周期hook
    installComponentHooks(data)

    // 4. 根据前面生成的数据,调用new VNode生成虚拟Dom
    const name = Ctor.options.name || tag
    const vnode = new VNode(
        `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
        data, undefined, undefined, undefined, context,
        { Ctor, propsData, listeners, tag, children },
        asyncFactory
    )
    return vnode
}

其中第三步合并生命周期hook函数在组件渲染挂载过程会被用到,这个在后续的Vue.component定义组件时继续讨论。

  • new VNode

VNode是一个类,包含一些描述该节点信息的实例成员,生成Vnode就是将一组数据组合成一个VNode实例。

_update

_update方法的作用是将虚拟Dom渲染到页面视图上:

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevVnode = vm._vnode
    if (!prevVnode) {
        // 首次渲染
        vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
        // 数据更新
        vm.$el = vm.__patch__(prevVnode, vnode)
    }
}

不论是首次渲染还是数据更新,其调用的都是__patch__方法。

__patch__过程会操作Dom,因此属于web平台上的特有操作,因此其定义在platforms/web/runtime/index.js中:

Vue.prototype.__patch__ = inBrowser ? patch : noop

其实现调用了platforms/web/runtime/patch.js中导出的patch函数。

patch

patch过程和snabbdom的patch过程非常相近,只是针对vuejs特殊语法做了一些修改,此处不再详细说明,可以参考Snabbdom实现原理

总结

虚拟Dom的整个渲染过程可以总结为以下几步:

  1. vue调用$mount挂载Dom。
  2. 判断创建Vue实例时是否传入render方法,如果没传,那么将根据模版生成render函数。
  3. 创建Watcher,传入updateComponent函数。
  4. Watcher实例化时,会判断此Watcher是否是渲染Watcher,如果是,则调用updateComponent。
  5. updateComponent函数中会调用_render方法生成虚拟Dom,调用_update方法根据传入的虚拟Dom渲染真实Dom。
  6. 如果数据发生变化,会通过__ob__属性指向的Dep通知第四步中创建的Watcher,Watcher内部会再次调用updateComponent执行更新渲染。

模版编译

只有在完整版的vuejs中才包含模版编译部分的代码,如果是通过vue-cli创建的项目,将没有此部分功能。

模版编译的过程包含如下几步:

  1. 解析Dom的ast语法树。
  2. 根据ast生成render字符串。
  3. 将render字符串转换为render函数。

编译入口

platforms/web/entry-runtime-with-compiler.js文件中,会判断$mount时是否传入了render函数,如果没有传入,会根据模版编译render函数。

Vue.prototype.$mount = function (
    el?: string | Element,
    hydrating?: boolean
): Component {
    // ...
    // 编译template为render函数
    const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
    }, this)
    // ...
}

其中,compileToFunctions返回的render函数就是最终生成虚拟Dom用的render函数,staticRenderFns为静态树优化,用于优化patch功能。

  • compileToFunctions

定义在platforms/web/compiler/index.js中:

const { compile, compileToFunctions } = createCompiler(baseOptions)

是由高阶函数createCompiler函数执行返回的。

  • createCompiler

定义在compilter/index.js文件中:

export const createCompiler = createCompilerCreator(function baseCompile(
    template: string,
    options: CompilerOptions
): CompiledResult {
    // 1. 生成ast
    const ast = parse(template.trim(), options)
    // 2. 针对ast进行优化
    if (options.optimize !== false) {
        optimize(ast, options)
    }
    // 生成render函数
    const code = generate(ast, options)
    return {
        ast,
        render: code.render,
        staticRenderFns: code.staticRenderFns
    }
})

是调用createCompilerCreator生成的,在调用时传入了编译核心函数baseCompile,该函数中将编译细化为三步:

  1. 生成ast。
  2. 优化ast
  3. 生成render函数
  • createCompilerCreator

定义在compiler/reate-compiler.js文件中,

export function createCompilerCreator(baseCompile: Function): Function {
    return function createCompiler(baseOptions: CompilerOptions) {
        // 编译函数:补充options,调用baseCompiler编译
        function compile(
            template: string,
            options?: CompilerOptions
        ): CompiledResult {
            // 1. 合并用户options和默认options,创建错误和提示数组对象。
            // 2. 调用baseCompile执行具体编译工作
            const compiled = baseCompile(template.trim(), finalOptions)
            // 3. 将编译过程中的错误和提示添加到编译结果上。
            return compiled
        }

        return {
            compile,
            compileToFunctions: createCompileToFunctionFn(compile)
        }
    }
}

其内部扩展了编译函数,添加了默认配置和错误收集,然后调用createCompileToFunctionFn生成最终的编译函数。

  • createCompileToFunctionFn

定义在compiler/to-function.js中:

export function createCompileToFunctionFn(compile: Function): Function {
    // 1. 添加编译结果缓存
    const cache = Object.create(null)
    return function compileToFunctions(
        template: string,
        options?: CompilerOptions,
        vm?: Component
    ): CompiledFunctionResult {
        // 2. 根据编译得到的render字符串调用new Function生成编译函数
        const res = {}
        const fnGenErrors = []
        res.render = createFunction(compiled.render, fnGenErrors)
        res.staticRenderFns = compiled.staticRenderFns.map(code => {
            return createFunction(code, fnGenErrors)
        })

        return (cache[key] = res)
    }
}

此方法继续扩展编译方法,提供了缓存和将render字符串转换成render函数功能。

ast语法

生成ast语法树:

const ast = parse(template.trim(), options)

其本质是调用parse函数将html字符串转换为普通js对象描述的树结构数据,内部调用的是http://erik.eae.net/simplehtmlparser/simplehtmlparser.js这个工具库,有兴趣的可以自己看一下。

优化ast

优化ast主要是找到并标记静态根节点,一旦标记静态根节点,那么就会带来两个好处:

  1. 把它们变成常数,这样我们就不需要了在每次重新渲染时为它们创建新的节点。
  2. 在patch过程中能够跳过这些静态根节点。

那么,什么是静态根节点呢?

静态根节点是指永远不会发生变化的Dom树,在Vuejs中,如果满足下面三个条件,就认为是静态根节点:

  1. 必须存在子节点。
  2. 如果子节点只有一个,该子节点不能是文本节点。
  3. 所有子节点都是静态节点(当数据发生变化的时候,节点不会发生改变)。
if (options.optimize !== false) {
    optimize(ast, options)
}

在编译的时候调用optimize函数执行具体的优化操作。

  • optimize

定义在compiler/optimize.js文件中:

export function optimize(root: ?ASTElement, options: CompilerOptions) {
    // ...
    // 1. 找到并标记所有的静态节点
    markStatic(root)
    // 2. 找到并标记所有静态根节点
    markStaticRoots(root, false)
}
  • markStatic
function markStatic (node: ASTNode) {
  // 1. 直接判断node节点是不是静态节点
  node.static = isStatic(node)
  if (node.type === 1) {
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&
      node.attrsMap['inline-template'] == null
    ) {
      return
    }
    // 2. 遍历子节点, 如果子节点其中一个为非静态节点,那么修改本节点为非静态节点。
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      if (!child.static) {
        node.static = false
      }
    }
    // 3. if节点的处理和第2步相同
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        if (!block.static) {
          node.static = false
        }
      }
    }
  }
}

如何判断是否是静态节点?

简单来讲,如果数据变化的时候,该节点会发生变化,那么此节点就不是静态节点,感兴趣的可自行查看isStatic内部实现。

  • markStaticRoots

用于查找并标记所有的静态根节点,判断依据可以参考前面提到的如何断定一个节点是静态根节点。

function markStaticRoots(node: ASTNode, isInFor: boolean) {
    if (node.type === 1) {
        // 节点被标记为静态节点,说明所有子节点都为静态节点
        if (node.static || node.once) {
            node.staticInFor = isInFor
        }
        // 包含至少一个子节点,如果包含一个子节点,此节点不是文本节点
        if (node.static && node.children.length && !(
            node.children.length === 1 &&
            node.children[0].type === 3
        )) {
            node.staticRoot = true
            return
        } else {
            node.staticRoot = false
        }
        // 遍历所有子节点进行标记
        if (node.children) {
            for (let i = 0, l = node.children.length; i < l; i++) {
                markStaticRoots(node.children[i], isInFor || !!node.for)
            }
        }
        // 遍历所有if节点标记
        if (node.ifConditions) {
            for (let i = 1, l = node.ifConditions.length; i < l; i++) {
                markStaticRoots(node.ifConditions[i].block, isInFor)
            }
        }
    }
}

那么优化的静态根节点在实际过程中如何使用呢?

以下面的模版为例:

<div id="app">
    <span>
      <strong>文本</strong>
    </span>
    <span>{{msg}}</span>
  </div>

编译成render函数内部如下:

with (this) {
    return _c('div',
        { attrs: { "id": "app" } },
        [
            _m(0),
            _v(" "),
            _c('span', [_v(_s(msg))])
        ])
}

在render函数中_m(0)返回的虚拟Dom代表的就是静态根节点:

<span>
    <strong>文本</strong>
</span>

静态根节点的Vnode结果缓存在staticRenderFns中,_m函数就是根据元素索引去获取缓存的结果,这样每次调用_render生成虚拟Dom的时候就可以使用缓存,避免重复渲染。

生成render函数

首先生成render字符串

const code = generate(ast, options)

generate内部根据ast生成render函数字符串:

export function generate(
    ast: ASTElement | void,
    options: CompilerOptions
): CodegenResult {
    const state = new CodegenState(options)
    const code = ast ? genElement(ast, state) : '_c("div")'
    return {
        render: `with(this){return ${code}}`,
        staticRenderFns: state.staticRenderFns
    }
}

然后在createCompileToFunctionFn函数中调用createFunction函数将render函数字符串转换为render函数:

function createFunction(code, errors) {
    try {
        return new Function(code)
    } catch (err) {
        errors.push({ err, code })
        return noop
    }
}

至此,整个渲染过程大功告成。

下一节会探讨vuejs一些常用实例方法的实现方式。

查看原文

赞 2 收藏 2 评论 0

认证与成就

  • 获得 10 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

(゚∀゚ )
暂时没有

注册于 7月14日
个人主页被 305 人浏览